├── .eslintrc.js ├── .github ├── CODEOWNERS └── workflows │ ├── bumpPrerelease │ ├── action.js │ ├── action.yml │ └── index.js │ ├── checks.yml │ ├── codeql-analysis.yml │ ├── prerelease.yaml │ └── release.yaml ├── .gitignore ├── .mocharc.yaml ├── .nycrc.yaml ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.hero.png ├── README.md ├── SECURITY.md ├── demos └── distinctNameMatching │ ├── .sarif │ └── log.sarif │ ├── bar.txt │ ├── baz.txt │ ├── folder │ └── bar.txt │ ├── foo.txt │ └── readme.md ├── icon.png ├── index.css ├── index.html ├── index.js ├── package-lock.json ├── package.json ├── samples ├── demoSarif.json ├── propertyBags.sarif └── rulesExtensions.sarif ├── src ├── extension │ ├── getOriginalDoc.ts │ ├── git.d.ts │ ├── index.activateDecorations.ts │ ├── index.activateFixes.ts │ ├── index.activateGithubAnalyses.ts │ ├── index.activateGithubCommands.ts │ ├── index.d.ts │ ├── index.spec.ts │ ├── index.ts │ ├── jsonSourceMap.d.ts │ ├── loadLogs.spec.ts │ ├── loadLogs.ts │ ├── measureDrift.md │ ├── measureDrift.ts │ ├── panel.ts │ ├── platform.ts │ ├── platformUriNormalize.ts │ ├── regionToSelection.ts │ ├── resultDiagnostic.ts │ ├── statusBarItem.ts │ ├── store.ts │ ├── stringTextDocument.ts │ ├── telemetry.ts │ ├── update.spec.ts │ ├── update.ts │ ├── uriExists.ts │ ├── uriRebaser.spec.ts │ └── uriRebaser.ts ├── panel │ ├── details.layouts.tsx │ ├── details.scss │ ├── details.tsx │ ├── detailsFeedback.scss │ ├── detailsFeedback.tsx │ ├── filterKeywordContext.ts │ ├── global.d.ts │ ├── index.scss │ ├── index.tsx │ ├── indexStore.ts │ ├── init.js │ ├── isActive.ts │ ├── resultTable.tsx │ ├── resultTableStore.spec.ts │ ├── resultTableStore.ts │ ├── table.scss │ ├── table.tsx │ ├── tableStore.spec.ts │ ├── tableStore.ts │ ├── widgets.scss │ └── widgets.tsx ├── shared │ ├── extension.spec.ts │ ├── extension.ts │ ├── index.spec.ts │ ├── index.ts │ ├── overrideBaseUri.spec.ts │ └── overrideBaseUri.ts └── test │ ├── mockLog.ts │ ├── mockResultTableStore.ts │ └── mockVscode.ts ├── tsconfig.json └── webpack.config.js /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const OFF = 0, ERROR = 2 2 | 3 | module.exports = { 4 | overrides: [ 5 | { 6 | files: ["src/**/*.ts{,x}"], 7 | extends: [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended" 11 | ], 12 | rules: { 13 | "new-cap": ERROR, 14 | "no-console": [ERROR, {allow: ["error", "warn"]}], 15 | "no-throw-literal": ERROR, 16 | "no-var": ERROR, 17 | "prefer-const": ERROR, 18 | 19 | "eqeqeq": ERROR, 20 | "filenames/match-regex": [ERROR, "^([a-z0-9]+)([A-Z][a-z0-9]+)*(\.(config|d|layouts|spec))?$"], 21 | "header/header": [ERROR, "line", [ 22 | " Copyright (c) Microsoft Corporation. All rights reserved.", 23 | " Licensed under the MIT License.", 24 | ]], 25 | "indent": [ERROR, 4, { "SwitchCase": 1 }], 26 | "no-trailing-spaces": ERROR, 27 | "quotes": [ERROR, "single", {"allowTemplateLiterals": true}], 28 | "semi": ERROR, 29 | "@typescript-eslint/member-delimiter-style": [ERROR, { 30 | "singleline": { 31 | "delimiter": "comma", 32 | } 33 | }], 34 | 35 | // Exceptions with Justifications. 36 | "no-undef": OFF, // Requires too many exception account for Mocha, Node.js and browser globals. Typescript also already checks for this. 37 | "@typescript-eslint/explicit-module-boundary-types": OFF, // Requires types on methods such as render() which can already be inferred. 38 | "@typescript-eslint/no-empty-function": OFF, // Too useful for mocks. Perhaps TODO enable for only non-test files. 39 | "@typescript-eslint/no-non-null-assertion": OFF, // Rule does not account for when the value has already been null-checked. 40 | "@typescript-eslint/no-unused-vars": OFF, // Not working with TSX. 41 | "@typescript-eslint/no-var-requires": OFF, // Making importing proxyquire too verbose since that library is not super Typescript friendly. 42 | "@typescript-eslint/triple-slash-reference": OFF, // Disallows and there's no workaround. 43 | }, 44 | } 45 | ], 46 | parser: "@typescript-eslint/parser", 47 | parserOptions: { 48 | ecmaVersion: 6, 49 | sourceType: "module", 50 | "ecmaFeatures": { 51 | "jsx": true 52 | }, 53 | }, 54 | plugins: [ 55 | "@typescript-eslint", 56 | "filenames", 57 | "header", 58 | ], 59 | } 60 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @GabeDeBacker @EasyRhinoMSFT @chrishuynhc @aeisenberg @huskydawg 2 | -------------------------------------------------------------------------------- /.github/workflows/bumpPrerelease/action.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | // Run `ncc build action.js --out .` to produce `index.js` 5 | const { execFileSync } = require('child_process'); 6 | const core = require("@actions/core"); 7 | const { readFileSync, writeFileSync } = require('fs'); 8 | const { parse } = require('semver'); 9 | 10 | const package = JSON.parse(readFileSync('./package.json', 'utf8')); 11 | const packageVer = parse(package.version); 12 | 13 | let prerelease = 0; // Main was changed, or no prev version, restart prerelease from 0. 14 | try { 15 | core.startGroup('Fetching tags'); 16 | execFileSync('git', ['fetch', '--tags']); 17 | core.endGroup(); 18 | 19 | core.startGroup('Looking for tags from commit history'); 20 | core.info(execFileSync('git', ['log', '--oneline'], { encoding: 'utf8' })); 21 | // `abbrev=0` finds the closest tagname without any suffix. 22 | // HEAD~1 assuming the latest commit hasn't been tagged by this Action yet. 23 | const tag = execFileSync('git', ['describe', '--tags', '--abbrev=0', 'HEAD~1'], { encoding: 'utf8' }).trim(); 24 | core.info('Tag for HEAD~1', tag); 25 | const lastReleaseVer = parse(tag); 26 | if (packageVer.compareMain(lastReleaseVer) === 0) { 27 | prerelease = lastReleaseVer.prerelease[0] + 1; // Main is equal, auto-increment the prerelease. 28 | } 29 | core.endGroup(); 30 | } catch (error) { 31 | } 32 | 33 | packageVer.prerelease = [ prerelease ]; 34 | package.version = packageVer.format(); 35 | core.info(`Computed package version: ${package.version}`); 36 | writeFileSync('./package.json', JSON.stringify(package, null, 4)); 37 | core.setOutput("version", package.version); 38 | -------------------------------------------------------------------------------- /.github/workflows/bumpPrerelease/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Bump Prerelease' 2 | runs: 3 | using: 'node12' 4 | main: 'index.js' 5 | outputs: 6 | version: 7 | description: "The version of current npm package" -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: Checks 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | workflow_dispatch: 9 | 10 | permissions: 11 | security-events: write # for sending sarif to code scanning 12 | contents: write # for accessing the cache 13 | 14 | 15 | jobs: 16 | eslint: 17 | timeout-minutes: 10 18 | runs-on: ubuntu-latest 19 | 20 | permissions: 21 | actions: read 22 | contents: read 23 | security-events: write 24 | 25 | steps: 26 | - uses: actions/checkout@v3 27 | 28 | - name: Install eslint and formatter 29 | run: npm i eslint @microsoft/eslint-formatter-sarif 30 | 31 | - name: Run eslint 32 | run: npx eslint . 33 | --format @microsoft/eslint-formatter-sarif 34 | --output-file eslint.sarif 35 | continue-on-error: true 36 | 37 | - name: Upload SARIF to GitHub 38 | uses: github/codeql-action/upload-sarif@v3 39 | with: 40 | sarif_file: eslint.sarif 41 | 42 | - name: Upload SARIF as artifact 43 | uses: actions/upload-artifact@v4 44 | with: 45 | name: eslint 46 | path: eslint.sarif 47 | 48 | 49 | unit-test: 50 | permissions: 51 | actions: read 52 | contents: read 53 | 54 | runs-on: ${{ matrix.os }} 55 | timeout-minutes: 10 56 | 57 | strategy: 58 | matrix: 59 | os: [ ubuntu-latest, windows-latest ] 60 | 61 | steps: 62 | - name: Checkout 63 | uses: actions/checkout@v2 64 | 65 | - name: Setup Node 66 | uses: actions/setup-node@v3 67 | with: 68 | node-version: '18' 69 | 70 | - name: Cache dependencies 71 | id: cache-dependencies 72 | uses: actions/cache@v3 73 | with: 74 | path: node_modules 75 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 76 | restore-keys: | 77 | ${{ runner.os }}-node- 78 | 79 | - name: Install dependencies 80 | if: steps.cache-dependencies.outputs.cache-hit != 'true' 81 | run: npm install 82 | 83 | - name: Run tests 84 | run: npm run test 85 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main, dev ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '42 11 * * 5' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript', 'actions' ] 36 | 37 | steps: 38 | - name: Checkout repository 39 | uses: actions/checkout@v4 40 | 41 | # Initializes the CodeQL tools for scanning. 42 | - name: Initialize CodeQL 43 | uses: github/codeql-action/init@v3 44 | with: 45 | languages: ${{ matrix.language }} 46 | 47 | - name: Perform CodeQL Analysis 48 | uses: github/codeql-action/analyze@v3 49 | -------------------------------------------------------------------------------- /.github/workflows/prerelease.yaml: -------------------------------------------------------------------------------- 1 | name: Create Pre-release 2 | on: 3 | # push: 4 | # branches: 5 | # - master 6 | workflow_dispatch: 7 | 8 | jobs: 9 | createPrerelease: 10 | name: Create Pre-release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | with: 15 | fetch-depth: 20 # Increase history for bumpPrerelease. Subsequent `git fetch --depth=20` not working. 16 | - uses: actions/cache@v4 17 | with: 18 | path: ~/.npm 19 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 20 | restore-keys: | 21 | ${{ runner.os }}-node- 22 | - id: bumpPrerelease 23 | uses: ./.github/workflows/bumpPrerelease 24 | - run: | 25 | npm install 26 | npm run package 27 | - id: create_release 28 | uses: actions/create-release@v1 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | with: 32 | tag_name: ${{ steps.bumpPrerelease.outputs.version }} 33 | release_name: ${{ steps.bumpPrerelease.outputs.version }} 34 | prerelease: true 35 | - id: upload-release-asset 36 | uses: actions/upload-release-asset@v1 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | with: 40 | upload_url: ${{ steps.create_release.outputs.upload_url }} 41 | asset_path: ./sarif-viewer-${{ steps.bumpPrerelease.outputs.version }}.vsix 42 | asset_name: sarif-viewer-${{ steps.bumpPrerelease.outputs.version }}.vsix 43 | asset_content_type: application/vsix 44 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | on: 3 | workflow_dispatch: 4 | 5 | jobs: 6 | createPrerelease: 7 | name: Create Release 8 | runs-on: ubuntu-latest 9 | permissions: 10 | # Release creation 11 | contents: write 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: actions/cache@v4 15 | with: 16 | path: ~/.npm 17 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 18 | restore-keys: | 19 | ${{ runner.os }}-node- 20 | - run: npm install 21 | name: npm install 22 | - run: npm install --global @vscode/vsce 23 | name: Install vsce 24 | - run: vsce package 25 | name: Create VSIX 26 | - id: package_version 27 | uses: Saionaro/extract-package-version@35ced6bfe3b1491af23de4db27c601697e6d8d17 28 | - id: create_release 29 | uses: actions/create-release@v1 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | with: 33 | tag_name: ${{ steps.package_version.outputs.version }} 34 | release_name: ${{ steps.package_version.outputs.version }} 35 | - uses: actions/upload-release-asset@v1 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | with: 39 | upload_url: ${{ steps.create_release.outputs.upload_url }} 40 | asset_path: ./sarif-viewer-${{ steps.package_version.outputs.version }}.vsix 41 | asset_name: sarif-viewer-${{ steps.package_version.outputs.version }}.vsix 42 | asset_content_type: application/vsix 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | 4 | out 5 | devServer/index.js 6 | *.vsix 7 | coverage 8 | .nyc_output 9 | ignore/ 10 | .vscode-test 11 | -------------------------------------------------------------------------------- /.mocharc.yaml: -------------------------------------------------------------------------------- 1 | # https://github.com/mochajs/mocha/blob/master/example/config/.mocharc.yml 2 | spec: src/**/*.spec.ts 3 | bail: true 4 | extension: 5 | - ts 6 | reporter: spec 7 | require: ts-node/register 8 | timeout: 3000 # For log upgrades. 9 | watch: false # Default is false, but leaving this here to make it easy to switch on. 10 | -------------------------------------------------------------------------------- /.nycrc.yaml: -------------------------------------------------------------------------------- 1 | extension: ['.ts'] 2 | exclude: ['src/**/*.spec.ts', 'src/test/*'] 3 | reporter: ['text', 'text-summary', 'html'] 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "configurations": [ 4 | { 5 | "name": "Launch Extension", 6 | "type": "extensionHost", 7 | "request": "launch", 8 | "runtimeExecutable": "${execPath}", 9 | "args": ["--extensionDevelopmentPath=${workspaceRoot}"], 10 | "sourceMaps": true, 11 | "outFiles": [ "${workspaceRoot}/out/**/*.js" ], 12 | "preLaunchTask": "npm: start" 13 | }, 14 | { 15 | "name": "Launch Mocha", 16 | "type": "node", 17 | "request": "launch", 18 | "program": "${workspaceRoot}/node_modules/.bin/mocha", 19 | "args": ["--color", "${file}"], 20 | "skipFiles": [ 21 | "/**/*.js", 22 | "${workspaceFolder}/node_modules/**/*.js", 23 | ] 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/out": true, 4 | "**/dist": true, 5 | "**/node_modules": true, 6 | "common/temp": true, 7 | "**/.vscode-test": true 8 | }, 9 | "files.watcherExclude": { 10 | "**/.git/**": true, 11 | "**/out": true, 12 | "**/dist": true, 13 | "**/node_modules": true, 14 | "common/temp": true, 15 | "**/.vscode-test": true 16 | }, 17 | "search.exclude": { 18 | "**/out": true, // set this to false to include "out" folder in search results 19 | "**/dist": true, 20 | "**/node_modules": true, 21 | "common/temp": true, 22 | "**/.vscode-test": true 23 | }, 24 | 25 | "cSpell.words": [ 26 | "Callout", 27 | "Callouts", 28 | "Codeflow", 29 | "Hacky", 30 | "Rebaser", 31 | "Resizer", 32 | "Retval", 33 | "Sarif", 34 | "Unmount", 35 | "abcdefghijklmnopqrstuvwxyz", 36 | "autorun", 37 | "codicon", 38 | "codicons", 39 | "devtools", 40 | "eqeqeq", 41 | "mobx", 42 | "multitool", 43 | "ploc", 44 | "rebased", 45 | "redirectable", 46 | "rloc", 47 | "tfloc", 48 | "vsix" 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "start", 7 | "isBackground": true, 8 | "promptOnClose": true, 9 | "problemMatcher": { 10 | "background": { 11 | "beginsPattern": "> webpack --watch --mode development", 12 | "endsPattern": "asset context\\.js" 13 | }, 14 | "pattern": [ 15 | { 16 | "regexp": "ERROR in (.+)$", 17 | "kind": "file", 18 | "file": 1 19 | }, 20 | { 21 | "regexp": "(.+)$", 22 | "message": 1 23 | } 24 | ] 25 | } 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | ** 2 | !out 3 | !icon.png 4 | 5 | !CHANGELOG.md 6 | !CONTRIBUTING.md 7 | !LICENSE 8 | !README.md 9 | !README.hero.png 10 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to SARIF Viewer 2 | 3 | 4 | 5 | ## Overview 6 | The instructions in this document will help you get started with the SARIF Viewer extension for Visual Studio Code. 7 | 8 | This extension conforms to patterns common to all Visual Studio Code extensions. We recommend reading [the official guide](https://code.visualstudio.com/api/get-started/your-first-extension). That guide may overlap with some topics covered in this guide. 9 | 10 | ### Prerequisites 11 | Proficiency of the following topics is required: 12 | * Typescript (https://www.typescriptlang.org/) 13 | * ReactJS (https://reactjs.org/) 14 | * Visual Studio Code Extensions (https://code.visualstudio.com/api) 15 | 16 | ### Architecture 17 | The extension is organized into two main parts: 18 | 19 | * `/src/extension` - The main entry point. It runs within a `Node.js` process[^1] and can be thought of as a background task. It does not directly draw any UI. 20 | * `/src/panel` - This runs within a [VS Code WebView](https://code.visualstudio.com/api/extension-guides/webview), which is essentially an `iframe`. This code is really just a web page which is rendered by ReactJS. In fact, during development, this page can be viewed directly in a browser. 21 | 22 | As these two parts run in separate processes, communication is limited to [message passing](https://code.visualstudio.com/api/extension-guides/webview#scripts-and-message-passing). Shared logic is refactored into `/src/shared`. 23 | 24 | [^1]: If running on the desktop. Otherwise see [here](https://code.visualstudio.com/api/advanced-topics/extension-host). 25 | 26 | 27 | 28 | ## Setup 29 | Make sure you have [GIT](https://git-scm.com/), [Visual Studio Code](https://code.visualstudio.com/), and [Node.js](https://nodejs.org/en/). 30 | For Node.js, the "LTS" version will be sufficient. 31 | 32 | ### Enlistment 33 | Run `git clone https://github.com/microsoft/sarif-vscode-extension.git` or an equivalent command. 34 | 35 | ### Local Build 36 | Build is already integrated with `F5`. If you must build separately, run `npx webpack`. 37 | 38 | 39 | 40 | ## Debugging 41 | 1) Place breakpoints at the first two "Key Break Point" locations (see below). 42 | 1) Start Debugging (`F5`). This will compile and run the extension in a new Extension Development Host window. 43 | 1) Run the `SARIF: Show Panel` command from the Command Palette in the new window. Your first breakpoint will hit. 44 | 1) Click "Open SARIF log" and pick a *.sarif file. Your second breakpoint will hit. 45 | 1) If you make changes to the source code, you can reload the Extension Development Host window by running the `Developer: Reload Window` command from the Command Palette of the that window. 46 | 1) To view console log output, run `Help > Toggle Developer Tools` from the menu of the Extension Development Host window. 47 | 48 | ### Key Break Points 49 | * `src/extension/index.ts` function `activate` - This covers all the one-time preparation before any SARIF Logs are loaded. 50 | * `src/extension/loadLogs.ts` function `loadLogs` - This runs each time one or more SARIF Logs are opened. 51 | * `src/panel/indexStore.ts` function `IndexStore.constructor`- This is the core of the WebView which houses the bulk of the UI. 52 | 53 | 54 | 55 | ## FAQ 56 | * Can I use [Visual Studio](https://visualstudio.microsoft.com/vs/) as my IDE? No, you must use Visual Studio Code. 57 | * Is there a solution file? No, Visual Studio Code projects are just folders. 58 | 59 | 60 | 61 | ## Legal 62 | This project welcomes contributions and suggestions. Most contributions require you to agree to a 63 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us 64 | the rights to use your contribution. For details, visit https://cla.microsoft.com. 65 | 66 | When you submit a pull request, a CLA-bot will automatically determine whether you need to provide 67 | a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions 68 | provided by the bot. You will only need to do this once across all repos using our CLA. 69 | 70 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 71 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 72 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 73 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. All rights reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE 22 | -------------------------------------------------------------------------------- /README.hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/sarif-vscode-extension/1037ae803db00a439f4fd2f65386f6b25435300d/README.hero.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # SARIF Viewer for Visual Studio Code 3 | 4 | A [Visual Studio Code](https://code.visualstudio.com/) [extension](https://marketplace.visualstudio.com/VSCode) that adds support for viewing [SARIF](https://sarifweb.azurewebsites.net/) logs. SARIF log results can be viewed as squiggles in your source, in the Problems list, or in a dedicated **SARIF Results Panel**. The **SARIF Results Panel** offers richer grouping, filtering, column, and details options over the standard Problems list. 5 | 6 | ![overview](README.hero.png) 7 | 8 | ## What's New? 9 | 10 | Version 3 incorporates many feedback-based improvements: 11 | * Improved keyboard accessibility within the **SARIF Results Panel**. Arrow key through the list of results. 12 | * Resizable details section within the **SARIF Results Panel**. 13 | * Generally improved performance and responsiveness. 14 | * Automatic reconciliation of URIs between the SARIF log and your local workspace in most cases. 15 | 16 | To focus our efforts, we have dropped some less-used and less-reliable features: 17 | * Support for old SARIF versions - We now strictly support the public standard version 2.1.0. Older versions can be upgraded with the standalone SARIF Multitool (via [nuget](https://www.nuget.org/packages/Sarif.Multitool/) and [npm](https://www.npmjs.com/package/@microsoft/sarif-multitool)). 18 | * Conversion of external formats to SARIF - We recommend the standalone SARIF Multitool (via [nuget](https://www.nuget.org/packages/Sarif.Multitool/) and [npm](https://www.npmjs.com/package/@microsoft/sarif-multitool)) for conversion. 19 | * **SARIF Results Panel** (previously "SARIF Explorer") view state is no longer exposed as settings. 20 | * The `rootpaths` setting as been removed. 21 | 22 | If these changes adversely affect your project, please [let us know](https://github.com/microsoft/sarif-vscode-extension/issues). 23 | 24 | ## Usage 25 | 26 | Install this extension from the [Extension Marketplace](https://code.visualstudio.com/docs/editor/extension-gallery) within Visual Studio Code. 27 | 28 | SARIF logs (`*.sarif`) can be opened several ways: 29 | * Open as a document. The **SARIF Results Panel** will automatically be shown. 30 | * Manually show the **SARIF Results Panel** with command `sarif.showPanel`. Then click "Open SARIF log". If logs are already open, open additional logs via the folder icon at the top of the **SARIF Results Panel**. 31 | * Call from another extension. See the "API" section below. 32 | 33 | We welcome feedback via [issues](https://github.com/microsoft/sarif-vscode-extension/issues). 34 | 35 | ## API 36 | An [extension-to-extension public API](https://code.visualstudio.com/api/references/vscode-api#extensions) is offered. This API is defined at `src/extension/index.d.ts`. An example of another extension calling this extension: 37 | ```javascript 38 | const sarifExt = extensions.getExtension('MS-SarifVSCode.sarif-viewer'); 39 | if (!sarifExt.isActive) await sarifExt.activate(); 40 | await sarifExt.exports.openLogs([ 41 | Uri.file('c:/samples/demo.sarif'), 42 | ]); 43 | ``` 44 | Note: TypeScript typings for `Extension` are forthcoming. 45 | 46 | ## Telemetry 47 | We collect basic anonymous information such as activation and the versions/schemas of any logs opened. Opt-in or out via the general Visual Studio Code setting `telemetry.enableTelemetry`. 48 | 49 | ## Development 50 | 51 | `F5` launches this extension. Subsequent changes are watched and rebuilt. Use command `workbench.action.reloadWindow` to see the changes. For more details see [Contributing to SARIF Viewer](CONTRIBUTING.md). 52 | 53 | Other common tasks: 54 | 55 | | Command | Comments | 56 | | --- | --- | 57 | | `npm run server` | Run the `Panel` standalone at `http://localhost:8000`. Auto-refreshes. | 58 | | `npm test` | Run and watch unit tests. Bails on failure. Useful during development. 59 | | `npm test -- --bail false --watch false` | Run tests once. Useful for reporting. 60 | | `npx webpack` | Build manually. | 61 | | `npx @vscode/vsce package` | Produce a VSIX. | 62 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/opensource/security/definition), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/opensource/security/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/opensource/security/pgpkey). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://aka.ms/opensource/security/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/opensource/security/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/opensource/security/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /demos/distinctNameMatching/.sarif/log.sarif: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json", 3 | "version": "2.1.0", 4 | "runs": [ 5 | { 6 | "tool": { 7 | "driver": { 8 | "name": "Demo Driver for distinctLocalNames" 9 | } 10 | }, 11 | "results": [ 12 | { 13 | "ruleId": "DEMO01", 14 | "message": { 15 | "text": "Distinct filename" 16 | }, 17 | "locations": [ 18 | { 19 | "physicalLocation": { 20 | "artifactLocation": { 21 | "uri": "file:///folder/foo.txt" 22 | }, 23 | "region": { 24 | "startLine": 2, 25 | "startColumn": 4, 26 | "endColumn": 6 27 | } 28 | } 29 | } 30 | ] 31 | }, 32 | { 33 | "ruleId": "DEMO01", 34 | "message": { 35 | "text": "Ambiguous workspace filename" 36 | }, 37 | "locations": [ 38 | { 39 | "physicalLocation": { 40 | "artifactLocation": { 41 | "uri": "file:///folder/bar.txt" 42 | }, 43 | "region": { 44 | "startLine": 2, 45 | "startColumn": 4, 46 | "endColumn": 6 47 | } 48 | } 49 | } 50 | ] 51 | }, 52 | { 53 | "ruleId": "DEMO01", 54 | "message": { 55 | "text": "Ambiguous log filename" 56 | }, 57 | "locations": [ 58 | { 59 | "physicalLocation": { 60 | "artifactLocation": { 61 | "uri": "file:///baz.txt" 62 | }, 63 | "region": { 64 | "startLine": 2, 65 | "startColumn": 4, 66 | "endColumn": 6 67 | } 68 | } 69 | } 70 | ] 71 | }, 72 | { 73 | "ruleId": "DEMO01", 74 | "message": { 75 | "text": "Ambiguous log filename" 76 | }, 77 | "locations": [ 78 | { 79 | "physicalLocation": { 80 | "artifactLocation": { 81 | "uri": "file:///folder/baz.txt" 82 | }, 83 | "region": { 84 | "startLine": 2, 85 | "startColumn": 4, 86 | "endColumn": 6 87 | } 88 | } 89 | } 90 | ] 91 | } 92 | ] 93 | } 94 | ] 95 | } 96 | -------------------------------------------------------------------------------- /demos/distinctNameMatching/bar.txt: -------------------------------------------------------------------------------- 1 | 1234567890 2 | 1234567890 3 | 1234567890 4 | -------------------------------------------------------------------------------- /demos/distinctNameMatching/baz.txt: -------------------------------------------------------------------------------- 1 | 1234567890 2 | 1234567890 3 | 1234567890 4 | -------------------------------------------------------------------------------- /demos/distinctNameMatching/folder/bar.txt: -------------------------------------------------------------------------------- 1 | 1234567890 2 | 1234567890 3 | 1234567890 4 | -------------------------------------------------------------------------------- /demos/distinctNameMatching/foo.txt: -------------------------------------------------------------------------------- 1 | 1234567890 2 | 1234567890 3 | 1234567890 4 | -------------------------------------------------------------------------------- /demos/distinctNameMatching/readme.md: -------------------------------------------------------------------------------- 1 | # Test Cases 2 | 3 | * distinct workspace filename + distinct log filename 4 | Expect: Open foo.txt, has squiggles. 5 | 6 | * ambiguous workspace filename 7 | Expect: Open bar.txt, no squiggles. 8 | 9 | * ambiguous log filename 10 | Expect: Open baz.txt, no squiggles. -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/sarif-vscode-extension/1037ae803db00a439f4fd2f65386f6b25435300d/icon.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 12 | 13 | 14 |
15 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | (async () => { 5 | const defaultState = { // Some duplicated from shared/index 6 | version: 0, 7 | filtersRow: { 8 | Level: { 9 | 'Error': true, 10 | 'Warning': true, 11 | 'Note': true, 12 | 'None': true, 13 | }, 14 | Baseline: { 15 | 'New': true, 16 | 'Unchanged': true, 17 | 'Updated': true, 18 | 'Absent': false, 19 | }, 20 | Suppression: { 21 | 'Not Suppressed': true, 22 | 'Suppressed': false, 23 | }, 24 | }, 25 | filtersColumn: { 26 | Columns: { 27 | 'Baseline': false, 28 | 'Suppression': false, 29 | 'Rule': false, 30 | }, 31 | }, 32 | }; 33 | const state = localStorage.getItem('state'); 34 | const store = new Store(JSON.parse(state) ?? defaultState, true); 35 | const file = 'samples/demoSarif.json'; 36 | const response = await fetch(file); 37 | const log = await response.json(); 38 | log._uri = `file:///Users/username/projects/${file}`; 39 | store.logs.push(log); 40 | document.body.classList.add('pageIndex'); // Alternatively 'pageDetailsLayouts'. 41 | ReactDOM.render( 42 | React.createElement(Index, {store}), // Alternatively 'DetailsLayouts'. 43 | document.getElementById('root'), 44 | ); 45 | })(); 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sarif-viewer", 3 | "displayName": "SARIF Viewer", 4 | "description": "Adds support for viewing SARIF logs", 5 | "author": "Microsoft Corporation", 6 | "license": "MIT", 7 | "version": "3.4.5", 8 | "publisher": "MS-SarifVSCode", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/Microsoft/sarif-vscode-extension.git" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/Microsoft/sarif-vscode-extension/issues" 15 | }, 16 | "icon": "icon.png", 17 | "categories": [ 18 | "Other" 19 | ], 20 | "keywords": [ 21 | "sarif" 22 | ], 23 | "engines": { 24 | "vscode": "^1.72.0" 25 | }, 26 | "activationEvents": [ 27 | "onLanguage:json", 28 | "onUri", 29 | "workspaceContains:.git", 30 | "workspaceContains:.sarif" 31 | ], 32 | "extensionDependencies": [ 33 | "vscode.git" 34 | ], 35 | "main": "./out/context.js", 36 | "contributes": { 37 | "configuration": { 38 | "title": "SARIF Viewer", 39 | "properties": { 40 | "sarif-viewer.rootpaths": { 41 | "type": "array", 42 | "description": "Add root paths for default mapping of locations in the sarif file that can't be found (ex. the local root directory of your repo)." 43 | }, 44 | "sarif-viewer.explorer.openWhenNoResults": { 45 | "description": "Indicates whether to open the explorer when there are no results in the log.", 46 | "type": "boolean", 47 | "default": true 48 | }, 49 | "sarif-viewer.connectToGithubCodeScanning": { 50 | "description": "Connect to GitHub and display any code scanning results. Setting takes effect on editor restart.", 51 | "type": "string", 52 | "enum": [ 53 | "off", 54 | "on", 55 | "prompt" 56 | ], 57 | "enumDescriptions": [ 58 | "If you do not anticipate having or using GitHub code scanning results, this will save compute and network resources.", 59 | "", 60 | "Intended for first-time users." 61 | ], 62 | "default": "prompt" 63 | }, 64 | "sarif-viewer.updateChannel": { 65 | "description": "Specifies the type of updates the extension receives.", 66 | "type": "string", 67 | "enum": [ 68 | "Default", 69 | "Insiders" 70 | ], 71 | "enumDescriptions": [ 72 | "Default channel.", 73 | "Insiders channel. Receives upcoming features and bug fixes at a faster rate." 74 | ], 75 | "default": "Default", 76 | "scope": "application" 77 | }, 78 | "sarif-viewer.githubCodeScanningInitialAlert": { 79 | "description": "Specifies an initial alert to load into the UI on when the IDE starts. This is meant to be set programmatically.", 80 | "type": "string", 81 | "ignoreSync": true, 82 | "scope": "machine" 83 | } 84 | } 85 | }, 86 | "languages": [ 87 | { 88 | "id": "json", 89 | "extensions": [ 90 | ".sarif" 91 | ] 92 | } 93 | ], 94 | "commands": [ 95 | { 96 | "command": "sarif.showPanel", 97 | "category": "SARIF", 98 | "title": "Show Panel" 99 | }, 100 | { 101 | "command": "sarif.clearState", 102 | "category": "SARIF", 103 | "title": "Clear State" 104 | }, 105 | { 106 | "command": "sarif.alertDismissFalsePositive", 107 | "category": "SARIF", 108 | "title": "Dismiss - False Positive" 109 | }, 110 | { 111 | "command": "sarif.alertDismissUsedInTests", 112 | "category": "SARIF", 113 | "title": "Dismiss - Used in Tests" 114 | }, 115 | { 116 | "command": "sarif.alertDismissWontFix", 117 | "category": "SARIF", 118 | "title": "Dismiss - Won't Fix" 119 | } 120 | ], 121 | "menus": { 122 | "webview/context": [ 123 | { 124 | "command": "sarif.alertDismissFalsePositive", 125 | "when": "webviewId == 'sarif' && webviewSection == 'isGithubAlert'" 126 | }, 127 | { 128 | "command": "sarif.alertDismissUsedInTests", 129 | "when": "webviewId == 'sarif' && webviewSection == 'isGithubAlert'" 130 | }, 131 | { 132 | "command": "sarif.alertDismissWontFix", 133 | "when": "webviewId == 'sarif' && webviewSection == 'isGithubAlert'" 134 | } 135 | ] 136 | } 137 | }, 138 | "scripts": { 139 | "prestart": "npm install", 140 | "start": "webpack --watch --mode development", 141 | "server": "webpack serve --mode development", 142 | "test": "mocha", 143 | "test:watch": "mocha --watch", 144 | "testcoverage": "nyc mocha", 145 | "vscode:prepublish": "webpack --mode production", 146 | "lint": "eslint src" 147 | }, 148 | "devDependencies": { 149 | "@actions/core": "1.9.1", 150 | "@actions/github": "2.1.1", 151 | "@types/follow-redirects": "1.8.0", 152 | "@types/mocha": "2.2.48", 153 | "@types/mock-require": "2.0.0", 154 | "@types/node": "10.12.21", 155 | "@types/node-fetch": "2.5.7", 156 | "@types/proxyquire": "1.3.28", 157 | "@types/react": "16.9.26", 158 | "@types/react-dom": "16.9.5", 159 | "@types/sarif": "2.1.3", 160 | "@types/semver": "7.1.0", 161 | "@types/sinon": "9.0.4", 162 | "@types/tmp": "0.1.0", 163 | "@types/url-join": "4.0.0", 164 | "@types/vscode": "1.57", 165 | "@typescript-eslint/eslint-plugin": "3.1.0", 166 | "@typescript-eslint/parser": "3.1.0", 167 | "@zeit/ncc": "0.22.1", 168 | "copy-webpack-plugin": "^9.1.0", 169 | "css-loader": "^6.3.0", 170 | "eslint": "7.1.0", 171 | "eslint-plugin-filenames": "1.3.2", 172 | "eslint-plugin-header": "3.0.0", 173 | "json-source-map": "0.6.1", 174 | "mocha": "10.8.2", 175 | "nyc": "15.1.0", 176 | "proxyquire": "2.1.3", 177 | "sass": "^1.49.9", 178 | "sass-loader": "^12.1.0", 179 | "sinon": "9.0.2", 180 | "style-loader": "^3.3.0", 181 | "ts-loader": "^9.2.6", 182 | "ts-node": "8.8.2", 183 | "tslint": "5.12.1", 184 | "typescript": "3.8.3", 185 | "webpack": "^5.94.0", 186 | "webpack-cli": "^4.8.0", 187 | "webpack-dev-server": "^4.2.1" 188 | }, 189 | "dependencies": { 190 | "@types/diff": "^5.0.2", 191 | "chokidar": "^3.3.0", 192 | "diff": "^3.5.0", 193 | "follow-redirects": "1.15.6", 194 | "https-proxy-agent": "5.0.0", 195 | "mobx": "5.15.4", 196 | "mobx-react": "6.1.8", 197 | "node-fetch": "2.6.7", 198 | "react": "16.13.1", 199 | "react-dom": "16.13.1", 200 | "react-markdown": "^5.0.3", 201 | "semver": "7.5.2", 202 | "stream-meter": "^1.0.4", 203 | "tmp": "0.1.0", 204 | "vscode-codicons": "0.0.2", 205 | "vscode-extension-telemetry": "0.1.6", 206 | "vscode-uri": "2.1.2" 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /samples/demoSarif.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json", 3 | "version": "2.1.0", 4 | "properties": { 5 | "comment": "A demo empty log for development/testing purposes" 6 | }, 7 | "runs": [ 8 | { 9 | "tool": { 10 | "driver": { 11 | "name": "Demo" 12 | } 13 | }, 14 | "results": [ 15 | { 16 | "ruleId": "DEMO01", 17 | "message": { 18 | "text": "A result" 19 | }, 20 | "locations": [ 21 | { 22 | "physicalLocation": { 23 | "artifactLocation": { 24 | "uri": "file:///foo/baz/bar.txt" 25 | } 26 | } 27 | } 28 | ] 29 | } 30 | ] 31 | } 32 | ] 33 | } -------------------------------------------------------------------------------- /samples/propertyBags.sarif: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json", 3 | "version": "2.1.0", 4 | "runs": [ 5 | { 6 | "tool": { 7 | "driver": { 8 | "name": "NoNameScanner" 9 | } 10 | }, 11 | "results": [ 12 | { 13 | "message": { 14 | "text": "Result with property bag." 15 | }, 16 | "properties": { 17 | "tags": ["openSource"], 18 | "sampleBoolean": true, 19 | "sampleNumber": 12345, 20 | "sampleString": "lorem ipsum", 21 | "sampleArray": ["foo", "bar", "baz"], 22 | "sampleObject": { "foo": "bar" }, 23 | "sampleNull": null, 24 | "veryLongPropertyName" : "Very long string. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." 25 | } 26 | } 27 | ], 28 | "columnKind": "utf16CodeUnits" 29 | }, 30 | { 31 | "tool": { 32 | "driver": { 33 | "name": "NoNameScanner" 34 | } 35 | }, 36 | "results": [ 37 | { 38 | "message": { 39 | "text": "Result without property bag." 40 | } 41 | } 42 | ], 43 | "columnKind": "utf16CodeUnits" 44 | } 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /samples/rulesExtensions.sarif: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", 3 | "version": "2.1.0", 4 | "runs": [ 5 | { 6 | "tool": { 7 | "driver": { 8 | "name": "CodeQL", 9 | "organization": "GitHub", 10 | "semanticVersion": "2.0.0" 11 | }, 12 | "extensions": [ 13 | { 14 | "name": "query-pack1", 15 | "guid": "e0000000-0000-1111-9999-000000000000", 16 | "organization": "GitHub", 17 | "semanticVersion": "1.0.0", 18 | "rules": [ 19 | { 20 | "id": "js/unused-local-variable-1", 21 | "guid": "00000000-0000-1111-9999-111111111111", 22 | "deprecatedIds": [ 23 | "OldIdentifier" 24 | ], 25 | "name": "js/unused-local-variable", 26 | "shortDescription": { 27 | "text": "js/unused-local-variable shortDescription 1.0.0" 28 | }, 29 | "fullDescription": { 30 | "text": "js/unused-local-variable fullDescription 1.0.0" 31 | }, 32 | "help": { 33 | "text": "TextHelp", 34 | "markdown": "This **is** `help`" 35 | }, 36 | "defaultConfiguration": {}, 37 | "properties": { 38 | "tags": [ 39 | "maintainability" 40 | ], 41 | "kind": "problem", 42 | "precision": "very-high", 43 | "id": "js/unused-local-variable-1", 44 | "problem.severity": "recommendation" 45 | } 46 | }, 47 | { 48 | "id": "com.lgtm/python-queries:py/unnecessary-pass-1", 49 | "name": "com.lgtm/python-queries:py/unnecessary-pass", 50 | "shortDescription": { 51 | "text": "com.lgtm/python-queries:py/unnecessary-pass shortDescription 1.0.0" 52 | }, 53 | "fullDescription": { 54 | "text": "com.lgtm/python-queries:py/unnecessary-pass fullDescription 1.0.0" 55 | }, 56 | "help": { 57 | "text": "TextHelp" 58 | }, 59 | "helpUri": "http://lgtm.com", 60 | "defaultConfiguration": {} 61 | } 62 | ] 63 | } 64 | ] 65 | }, 66 | "results": [ 67 | { 68 | "ruleId": "js/unused-local-variable-1", 69 | "rule": { 70 | "guid": "00000000-0000-1111-9999-111111111111", 71 | "toolComponent": { 72 | "index": 0 73 | } 74 | }, 75 | "locations": [ 76 | { 77 | "physicalLocation": { 78 | "artifactLocation": { 79 | "uri": "file.js" 80 | }, 81 | "region": { 82 | "startLine": 1 83 | } 84 | } 85 | } 86 | ], 87 | "message": { 88 | "text": "Unused variable foo." 89 | } 90 | }, 91 | { 92 | "ruleIndex": 0, 93 | "rule": { 94 | "guid": "00000000-0000-1111-9999-111111111111", 95 | "toolComponent": { 96 | "index": 0 97 | } 98 | }, 99 | "locations": [ 100 | { 101 | "physicalLocation": { 102 | "artifactLocation": { 103 | "uri": "file.js" 104 | }, 105 | "region": { 106 | "startLine": 2 107 | } 108 | } 109 | } 110 | ], 111 | "message": { 112 | "text": "Unused variable foo." 113 | } 114 | }, 115 | { 116 | "rule": { 117 | "id": "js/unused-local-variable-1", 118 | "toolComponent": { 119 | "guid": "e0000000-0000-1111-9999-000000000000" 120 | } 121 | }, 122 | "locations": [ 123 | { 124 | "physicalLocation": { 125 | "artifactLocation": { 126 | "uri": "file.js" 127 | }, 128 | "region": { 129 | "startLine": 3 130 | } 131 | } 132 | } 133 | ], 134 | "message": { 135 | "text": "Unused variable foo." 136 | } 137 | }, 138 | { 139 | "rule": { 140 | "index": 0, 141 | "toolComponent": { 142 | "guid": "e0000000-0000-1111-9999-000000000000" 143 | } 144 | }, 145 | "locations": [ 146 | { 147 | "physicalLocation": { 148 | "artifactLocation": { 149 | "uri": "file.js" 150 | }, 151 | "region": { 152 | "startLine": 4 153 | } 154 | } 155 | } 156 | ], 157 | "message": { 158 | "text": "Unused variable foo." 159 | } 160 | }, 161 | { 162 | "ruleId": "com.lgtm/python-queries:py/unnecessary-pass-1", 163 | "rule": { 164 | "index": 0, 165 | "toolComponent": { 166 | "index": 0 167 | } 168 | }, 169 | "locations": [ 170 | { 171 | "physicalLocation": { 172 | "artifactLocation": { 173 | "uri": "file.js" 174 | }, 175 | "region": { 176 | "startLine": 5 177 | } 178 | } 179 | } 180 | ], 181 | "message": { 182 | "text": "Unnecessary pass." 183 | } 184 | } 185 | ] 186 | } 187 | ] 188 | } 189 | -------------------------------------------------------------------------------- /src/extension/getOriginalDoc.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import { EndOfLine, Uri } from 'vscode'; 5 | import { API, Repository } from './git'; 6 | import { getInitializedGitApi, getPrimaryRepository } from './index.activateGithubAnalyses'; 7 | import { StringTextDocument } from './stringTextDocument'; 8 | 9 | // If a uri belongs to a sub-module, we will not have the commit-info to make use of the repo. 10 | // Thus we act like the repo doesn't exist (which causes downstream code to bypass anti-drifting). 11 | export function getRepositoryForUri(git: API, uri: string): Repository | undefined { 12 | const primaryRepo = getPrimaryRepository(git); 13 | const submoduleRepos = git.repositories 14 | .filter(repo => repo.rootUri.toString() !== primaryRepo?.rootUri.toString()); 15 | const uriIsInSubmodule = submoduleRepos.some(repo => uri.startsWith(repo.rootUri.toString())); 16 | if (uriIsInSubmodule) return undefined; 17 | return primaryRepo; 18 | } 19 | 20 | // Used to force the original doc line endings to match the current doc. 21 | function coerceLineEndings(text: string, eol: EndOfLine) { 22 | if (eol === EndOfLine.LF) return text.replace(/\r\n/g, '\n'); 23 | if (eol === EndOfLine.CRLF) return text.replace(/\n/g , '\r\n'); 24 | return text; 25 | } 26 | 27 | // TODO: Consider caching the retval. 28 | export async function getOriginalDoc( 29 | commitSha: string | undefined, 30 | currentDoc: { uri: Uri, eol: EndOfLine }) 31 | : Promise { 32 | 33 | if (!commitSha) return undefined; 34 | 35 | const git = await getInitializedGitApi(); 36 | if (!git) return undefined; 37 | 38 | const repo = getRepositoryForUri(git, currentDoc.uri.toString()); 39 | 40 | if (!repo) return undefined; 41 | 42 | const scannedFile = await repo.show(commitSha, currentDoc.uri.fsPath); 43 | return new StringTextDocument(coerceLineEndings(scannedFile, currentDoc.eol)); 44 | } 45 | -------------------------------------------------------------------------------- /src/extension/index.activateDecorations.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | /* eslint-disable filenames/match-regex */ 4 | 5 | import { diffChars } from 'diff'; 6 | import { IArraySplice, observable, observe } from 'mobx'; 7 | import { Log } from 'sarif'; 8 | import { Disposable, languages, Range, ThemeColor, window } from 'vscode'; 9 | import { findResult, parseArtifactLocation, ResultId } from '../shared'; 10 | import '../shared/extension'; 11 | import { getOriginalDoc } from './getOriginalDoc'; 12 | import { driftedRegionToSelection } from './regionToSelection'; 13 | import { ResultDiagnostic } from './resultDiagnostic'; 14 | import { Store } from './store'; 15 | import { UriRebaser } from './uriRebaser'; 16 | 17 | // Decorations are for Analysis Steps. 18 | export function activateDecorations(disposables: Disposable[], store: Store, baser: UriRebaser) { 19 | // Navigating away from a diagnostic/result will not clear the `activeResultId`. 20 | // This keeps the decorations "pinned" while users navigate the thread flow steps. 21 | const activeResultId = observable.box(); 22 | 23 | const decorationTypeCallout = window.createTextEditorDecorationType({ 24 | after: { color: new ThemeColor('problemsWarningIcon.foreground') } 25 | }); 26 | const decorationTypeHighlight = window.createTextEditorDecorationType({ 27 | border: '1px', 28 | borderStyle: 'solid', 29 | borderColor: new ThemeColor('problemsWarningIcon.foreground'), 30 | }); 31 | 32 | // On selection change, set the `activeResultId`. 33 | disposables.push(languages.registerCodeActionsProvider('*', { 34 | provideCodeActions: (_doc, _range, context) => { 35 | if (context.only) return undefined; 36 | 37 | const diagnostic = context.diagnostics[0] as ResultDiagnostic | undefined; 38 | if (!diagnostic) return undefined; 39 | 40 | const result = diagnostic?.result; 41 | if (!result) return undefined; // Don't clear the decorations. See `activeResultId` comments. 42 | 43 | activeResultId.set(JSON.stringify(result._id)); // Stringify for comparability. 44 | 45 | // Technically should be using `onDidChangeTextEditorSelection` and `languages.getDiagnostics` 46 | // then manually figuring with diagnostics are at the caret. However `languages.registerCodeActionsProvider` 47 | // provides the diagnostics for free. The only odd part is that we always return [] when `provideCodeActions` is called. 48 | return []; 49 | } 50 | })); 51 | 52 | // Update decorations on: 53 | // * `activeResultId` change 54 | // * `window.visibleTextEditors` change 55 | // * `store.logs` item removed 56 | // We don't trigger on log added as the user would need to select a result first. 57 | async function update() { 58 | const resultId = activeResultId.get(); 59 | if (!resultId) { 60 | // This code path is only expected if `activeResultId` has not be set yet. See `activeResultId` comments. 61 | // Thus we are not concerned with clearing any previously rendered decorations. 62 | return; 63 | } 64 | const result = findResult(store.logs, JSON.parse(resultId) as ResultId); 65 | if (!result) { 66 | // Only in rare cases does `findResult` fail to resolve a `resultId` into a `result`. 67 | // Such as if a log were closed after an `activeResultId` was set. 68 | return; 69 | } 70 | 71 | for (const editor of window.visibleTextEditors) { 72 | const currentDoc = editor.document; 73 | const locations = result.codeFlows?.[0]?.threadFlows?.[0]?.locations ?? []; 74 | 75 | const locationsInDoc = locations.filter(async tfl => { 76 | const [artifactUriString] = parseArtifactLocation(result, tfl.location?.physicalLocation?.artifactLocation); 77 | return await baser.translateLocalToArtifact(currentDoc.uri) === artifactUriString; 78 | }); 79 | 80 | const originalDoc = await getOriginalDoc(store.analysisInfo?.commit_sha, currentDoc); 81 | const diffBlocks = originalDoc ? diffChars(originalDoc.getText(), currentDoc.getText()) : []; 82 | const ranges = locationsInDoc.map(tfl => driftedRegionToSelection(diffBlocks, currentDoc, tfl.location?.physicalLocation?.region, originalDoc)); 83 | editor.setDecorations(decorationTypeHighlight, ranges); 84 | 85 | { // Sub-scope for callouts. 86 | const messages = locationsInDoc.map((tfl) => { 87 | const text = tfl.location?.message?.text; 88 | return `Step ${locations.indexOf(tfl) + 1}${text ? `: ${text}` : ''}`; 89 | }); 90 | const rangesEnd = ranges.map(range => { 91 | const endPos = currentDoc.lineAt(range.end.line).range.end; 92 | return new Range(endPos, endPos); 93 | }); 94 | const rangesEndAdj = rangesEnd.map(range => { 95 | const tabCount = currentDoc.lineAt(range.end.line).text.match(/\t/g)?.length ?? 0; 96 | const tabCharAdj = tabCount * (editor.options.tabSize as number - 1); // Intra-character tabs are counted wrong. 97 | return range.end.character + tabCharAdj; 98 | }); 99 | const maxRangeEnd = Math.max(...rangesEndAdj) + 2; // + for Padding 100 | const decorCallouts = rangesEnd.map((range, i) => ({ 101 | range, 102 | hoverMessage: messages[i], 103 | renderOptions: { after: { contentText: ` ${'┄'.repeat(maxRangeEnd - rangesEndAdj[i])} ${messages[i]}`, } }, // ← 104 | })); 105 | editor.setDecorations(decorationTypeCallout, decorCallouts); 106 | } 107 | } 108 | } 109 | 110 | disposables.push({ dispose: observe(activeResultId, update) }); 111 | disposables.push(window.onDidChangeVisibleTextEditors(update)); 112 | disposables.push({ dispose: observe(store.logs, change => { 113 | const {removed} = change as unknown as IArraySplice; 114 | if (!removed.length) return; 115 | window.visibleTextEditors.forEach(editor => { 116 | editor.setDecorations(decorationTypeCallout, []); 117 | editor.setDecorations(decorationTypeHighlight, []); 118 | }); 119 | }) }); 120 | } 121 | -------------------------------------------------------------------------------- /src/extension/index.activateFixes.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | /* eslint-disable filenames/match-regex */ 4 | 5 | import { diffChars } from 'diff'; 6 | import { Fix, Result } from 'sarif'; 7 | import { CodeAction, CodeActionKind, Diagnostic, Disposable, languages, OutputChannel, Uri, workspace, WorkspaceEdit } from 'vscode'; 8 | import { parseArtifactLocation } from '../shared'; 9 | import { getOriginalDoc } from './getOriginalDoc'; 10 | import { driftedRegionToSelection } from './regionToSelection'; 11 | import { ResultDiagnostic } from './resultDiagnostic'; 12 | import { Store } from './store'; 13 | import { UriRebaser } from './uriRebaser'; 14 | import { getInitializedGitApi } from './index.activateGithubAnalyses'; 15 | import * as path from 'path'; 16 | import * as os from 'os'; 17 | 18 | export function activateFixes(disposables: Disposable[], store: Pick, baser: UriRebaser) { 19 | disposables.push(languages.registerCodeActionsProvider('*', 20 | { 21 | provideCodeActions(_doc, _range, context) { 22 | // Observed values `context`: 23 | // context.only │ context.triggerKind │ remarks 24 | // ──────────────────────┼──────────────────────┼──────── 25 | // undefined │ Automatic=2 │ After document load. Return all code actions. 26 | // { value: 'quickFix' } │ Invoke=1 │ Before hover tooltip is shown. Return only specific code actions. 27 | 28 | const diagnostic = context.diagnostics[0] as ResultDiagnostic | undefined; 29 | if (!diagnostic) return undefined; 30 | 31 | const result = diagnostic?.result; 32 | if (!result) return undefined; 33 | 34 | return [ 35 | new ResultQuickFix(diagnostic, result), // Mark as fixed 36 | ...result.fixes?.map(fix => new ResultQuickFix(diagnostic, result, fix)) ?? [], 37 | ...result.properties?.['github/alertNumber'] === undefined ? [] : [ // Assumes only GitHub will use `github/alertNumber`. 38 | new DismissCodeAction(diagnostic, result, 'sarif.alertDismissFalsePositive', 'False Positive'), 39 | new DismissCodeAction(diagnostic, result, 'sarif.alertDismissUsedInTests', 'Used in Tests'), 40 | new DismissCodeAction(diagnostic, result, 'sarif.alertDismissWontFix', 'Won\'t Fix'), 41 | ], 42 | ]; 43 | }, 44 | async resolveCodeAction(codeAction: ResultQuickFix) { 45 | const { result, fix, command } = codeAction; 46 | 47 | if (command) return undefined; // VS Code will execute the command on our behalf. 48 | 49 | if (fix) { 50 | await applyFix(fix, result, baser, store); 51 | } 52 | 53 | store.resultsFixed.push(JSON.stringify(result._id)); 54 | return codeAction; 55 | }, 56 | }, 57 | { 58 | providedCodeActionKinds: [CodeActionKind.QuickFix] 59 | }, 60 | )); 61 | } 62 | 63 | class ResultQuickFix extends CodeAction { 64 | constructor(diagnostic: Diagnostic, readonly result: Result, readonly fix?: Fix) { 65 | // If `fix` then use the `fix.description` 66 | // If no `fix` then intent is 'Mark as fixed'. 67 | super(fix ? (fix.description?.text ?? '?') : 'Mark as fixed', CodeActionKind.QuickFix); 68 | this.diagnostics = [diagnostic]; // Note: VSCode does not use this to clear the diagnostic. 69 | } 70 | } 71 | 72 | class DismissCodeAction extends CodeAction { 73 | constructor(diagnostic: Diagnostic, result: Result, command: string, reasonText: string) { 74 | super(`Dismiss - ${reasonText}`, CodeActionKind.Empty); 75 | this.diagnostics = [diagnostic]; // Note: VSCode does not use this to clear the diagnostic. 76 | this.command = { 77 | title: '', // Leaving empty as it is seemingly not used (yet required). 78 | command, 79 | arguments: [{ resultId: JSON.stringify(result._id) }], 80 | }; 81 | } 82 | } 83 | 84 | export async function applyFix(fix: Fix, result: Result, baser: UriRebaser, store: Pick, outputChannel?: OutputChannel) { 85 | // Some fixes are injected as raw diffs. If so, apply them directly. 86 | const diff = fix.properties?.diff; 87 | if (diff) { 88 | outputChannel?.appendLine('diff found:'); 89 | outputChannel?.appendLine('--------'); 90 | outputChannel?.appendLine(diff); 91 | outputChannel?.appendLine('--------'); 92 | const git = await getInitializedGitApi(); 93 | if (!git) { 94 | throw new Error('Unable to initialize Git API.'); 95 | } 96 | // save diff to a temp file 97 | const filePath = path.join(os.tmpdir(), `${(new Date()).getTime()}.patch`); 98 | try { 99 | await workspace.fs.writeFile(Uri.parse(filePath), Buffer.from(diff, 'utf-8')); 100 | // TODO assume exactly one repository, which will usually be the case for codespaces. 101 | // All the situations we need to handle right now are single repository. 102 | await git?.repositories[0].apply(filePath); 103 | outputChannel?.appendLine('diff applied.'); 104 | } finally { 105 | await workspace.fs.delete(Uri.parse(filePath)); 106 | } 107 | return; 108 | } 109 | outputChannel?.appendLine('Edit found.'); 110 | const edit = new WorkspaceEdit(); 111 | for (const artifactChange of fix.artifactChanges) { 112 | const [uri, uriBase] = parseArtifactLocation(result, artifactChange.artifactLocation); 113 | const artifactUri = uri; 114 | if (!artifactUri) continue; 115 | 116 | const localUri = await baser.translateArtifactToLocal(artifactUri, uriBase); 117 | if (!localUri) continue; 118 | outputChannel?.appendLine(`Applying fix to ${localUri.toString()}`); 119 | 120 | const currentDoc = await workspace.openTextDocument(localUri); 121 | const originalDoc = await getOriginalDoc(store.analysisInfo?.commit_sha, currentDoc); 122 | const diffBlocks = originalDoc ? diffChars(originalDoc.getText(), currentDoc.getText()) : []; 123 | 124 | for (const replacement of artifactChange.replacements) { 125 | edit.replace( 126 | localUri, 127 | driftedRegionToSelection(diffBlocks, currentDoc, replacement.deletedRegion, originalDoc), 128 | replacement.insertedContent?.text ?? '', 129 | ); 130 | } 131 | } 132 | workspace.applyEdit(edit); 133 | } 134 | -------------------------------------------------------------------------------- /src/extension/index.activateGithubCommands.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | /* eslint-disable filenames/match-regex */ 4 | 5 | import fetch, { Response } from 'node-fetch'; 6 | import { authentication, commands, Disposable, OutputChannel } from 'vscode'; 7 | import { findResult, ResultId } from '../shared'; 8 | import { Store } from './store'; 9 | 10 | // As defined by https://docs.github.com/en/rest/code-scanning#update-a-code-scanning-alert 11 | type DismissedReason = 'false positive' | 'won\'t fix' | 'used in tests' 12 | 13 | export function activateGithubCommands(disposables: Disposable[], store: Store, outputChannel: OutputChannel) { 14 | // Unfortunately, `resultId` is wrapped with a `context` object as a result of how VS Code Webview context menus work. 15 | async function dismissAlert(context: { resultId: string }, reason: DismissedReason) { 16 | const { resultId } = context; 17 | const result = findResult(store.logs, JSON.parse(resultId) as ResultId); 18 | if (!result) return; 19 | 20 | const logUri = result._log._uri; // Sample: https://api.github.com/repos/microsoft/binskim/code-scanning/analyses/46889472 21 | const alertNumber = result.properties?.['github/alertNumber']; 22 | if (!logUri || alertNumber === undefined) return; 23 | 24 | const [, ownerAndRepo] = logUri.match(/https:\/\/api\.github\.com\/repos\/([^/]+\/[^/]+\/code-scanning)\/analyses\/\d+/) ?? []; 25 | if (!ownerAndRepo) return; 26 | 27 | // API: https://docs.github.com/en/rest/code-scanning#update-a-code-scanning-alert 28 | // Sample: https://api.github.com/repos/microsoft/binskim/code-scanning/alerts/74 29 | const response = await callGithubRepos(`${ownerAndRepo}/alerts/${alertNumber}`, { 30 | state: 'dismissed', 31 | dismissed_reason: reason 32 | }); 33 | 34 | if (!response) { 35 | outputChannel.appendLine('No response'); 36 | return; 37 | } 38 | 39 | if (response.status !== 200) { 40 | store.resultsFixed.push(resultId); 41 | } else { 42 | const json = await response.json(); // { message, documentation_url } 43 | outputChannel.appendLine(`Status ${response.status} - ${json.message}`); 44 | } 45 | } 46 | 47 | disposables.push( 48 | commands.registerCommand('sarif.alertDismissFalsePositive', async (context) => dismissAlert(context, 'false positive')), 49 | commands.registerCommand('sarif.alertDismissUsedInTests', async (context) => dismissAlert(context, 'used in tests')), 50 | commands.registerCommand('sarif.alertDismissWontFix', async (context) => dismissAlert(context, 'won\'t fix')), 51 | ); 52 | } 53 | 54 | // `api` does not include leading slash. 55 | async function callGithubRepos(api: string, body: Record | undefined): Promise { 56 | const session = await authentication.getSession('github', ['security_events'], { createIfNone: true }); 57 | const { accessToken } = session; 58 | if (!accessToken) return undefined; 59 | 60 | try { 61 | // Useful for debugging the progress indicator: await new Promise(resolve => setTimeout(resolve, 2000)); 62 | return await fetch(`https://api.github.com/repos/${api}`, { 63 | headers: { 64 | 'Authorization': `Bearer ${accessToken}`, 65 | 'Content-Type': 'application/json' 66 | }, 67 | method: 'PATCH', 68 | body: body && JSON.stringify(body), 69 | }); 70 | } catch (error) { 71 | // Future: Pipe `error` to OutputChannel. Need to make OutputChannel exportable. 72 | return undefined; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/extension/index.d.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import { CancellationToken, Uri } from 'vscode'; 5 | 6 | /** 7 | * This API is consumed by other extensions. Breaking changes to this API must 8 | * be reflected in the major version number of the extension. 9 | */ 10 | export interface Api { 11 | /** 12 | * Note: If a log has been modified after open was opened, a close and re-open will be required to "refresh" that log. 13 | * @param logs An array of Uris to open. 14 | */ 15 | openLogs(logs: Uri[]): Promise; 16 | closeLogs(logs: Uri[], _options?: unknown, cancellationToken?: CancellationToken): Promise; 17 | closeAllLogs(): Promise; 18 | selectByIndex(uri: Uri, runIndex: number, resultIndex: number): Promise; 19 | uriBases: ReadonlyArray; 20 | dispose(): void; 21 | } 22 | -------------------------------------------------------------------------------- /src/extension/index.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | /// 5 | /// Changes to global.d.ts require Mocha restart. 6 | /// Todo: Migrate to tsconfig.files 7 | 8 | import assert from 'assert'; 9 | import { postSelectLog } from '../panel/indexStore'; 10 | import { log } from '../test/mockLog'; 11 | import { mockVscode, mockVscodeTestFacing, uriForRealFile } from '../test/mockVscode'; 12 | import { URI as Uri } from 'vscode-uri'; 13 | import { Api } from './index.d'; 14 | 15 | // Log object may be modified during testing, thus we need to keep a clean string copy. 16 | const mockLogString = JSON.stringify(log, null, 2); 17 | 18 | const proxyquire = require('proxyquire').noCallThru(); 19 | 20 | let api: Api; 21 | 22 | // TODO Tests are hanging on CI. 23 | describe.skip('activate', () => { 24 | before(async () => { 25 | const { activate } = proxyquire('.', { 26 | 'fs': { 27 | readFileSync: () => { 28 | return mockLogString; 29 | } 30 | }, 31 | 'vscode': { 32 | '@global': true, 33 | ...mockVscode, 34 | }, 35 | './telemetry': { 36 | activate: () => { }, 37 | deactivate: () => { }, 38 | }, 39 | }); 40 | api = await mockVscodeTestFacing.activateExtension(activate); 41 | await api.openLogs([uriForRealFile]); 42 | mockVscode.window.createWebviewPanel(); 43 | }); 44 | 45 | after(() => { 46 | api.dispose(); 47 | }); 48 | 49 | it('can postSelectArtifact', async () => { 50 | await mockVscode.commands.executeCommand('sarif.showPanel'); 51 | const { postSelectArtifact } = proxyquire('../panel/indexStore', { 52 | '../panel/isActive': { 53 | isActive: () => true, 54 | }, 55 | }); 56 | mockVscodeTestFacing.showOpenDialogResult = [Uri.file('/file.txt')]; 57 | const result = mockVscodeTestFacing.store!.results[0]!; 58 | await postSelectArtifact(result, result.locations![0].physicalLocation); 59 | assert.deepStrictEqual(mockVscodeTestFacing.events.splice(0), [ 60 | 'showTextDocument file:///file.txt', 61 | 'selection 0 1 0 2', 62 | ]); 63 | }); 64 | 65 | it('can postSelectLog', async () => { 66 | const result = mockVscodeTestFacing.store!.results[0]; 67 | mockVscodeTestFacing.showOpenDialogResult = [uriForRealFile]; 68 | await postSelectLog(result); 69 | assert.deepStrictEqual(mockVscodeTestFacing.events.splice(0), [ 70 | `showTextDocument ${uriForRealFile.toString()}`, 71 | 'selection 10 15 24 16', // Location in mockLogString. 72 | ]); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /src/extension/jsonSourceMap.d.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | declare module 'json-source-map' { 5 | function parse(json: string): unknown 6 | } 7 | -------------------------------------------------------------------------------- /src/extension/loadLogs.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | /* eslint-disable @typescript-eslint/no-explicit-any */ // Allowing any for mocks. 5 | 6 | import assert from 'assert'; 7 | import { Log } from 'sarif'; 8 | import { URI as Uri } from 'vscode-uri'; 9 | import '../shared/extension'; 10 | 11 | const proxyquire = require('proxyquire').noCallThru(); 12 | 13 | describe('loadLogs', () => { 14 | const files: Record = { 15 | '/Double.sarif': { 16 | $schema: 'https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.4.json', 17 | version: '2.1.0', 18 | }, 19 | '/EmbeddedContent.sarif': { 20 | $schema: 'https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json', 21 | version: '2.1.0', 22 | }, 23 | '/bad-eval-with-code-flow.sarif': { 24 | version: '2.1.0', 25 | }, 26 | '/oldLog.sarif': { 27 | $schema: 'http://json.schemastore.org/sarif-2.0.0-csd.2.beta.2019-01-24', 28 | version: '2.0.0-csd.2.beta.2019-01-24', 29 | }, 30 | }; 31 | const uris = Object.keys(files).map(path => Uri.file(path)); 32 | const stubs = { 33 | 'fs': { 34 | readFileSync: (fsPath: string) => JSON.stringify(files[Uri.file(fsPath).path]), 35 | }, 36 | 'vscode': { 37 | Uri, 38 | window: { 39 | showWarningMessage: () => { }, 40 | }, 41 | workspace: { 42 | workspaceFolders: undefined, 43 | }, 44 | }, 45 | './telemetry': { 46 | activate: () => { }, 47 | sendLogVersion: () => { }, 48 | }, 49 | }; 50 | 51 | it('loads', async () => { 52 | const { loadLogs } = proxyquire('./loadLogs', stubs); 53 | const logs = await loadLogs(uris) as Log[]; 54 | assert.strictEqual(logs.every(log => log.version === '2.1.0'), true); 55 | }); 56 | 57 | it('detects supported vs unsupported logs', async () => { 58 | const logsSupported = [] as Log[]; 59 | const logsNotSupported = [] as Log[]; 60 | const { detectSupport } = proxyquire('./loadLogs', stubs); 61 | 62 | detectSupport({ 63 | version: '2.1.0', 64 | $schema: 'https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json', 65 | } as any, logsSupported, logsNotSupported); 66 | assert.strictEqual(logsSupported.length, 1); 67 | assert.strictEqual(logsNotSupported.length, 0); 68 | 69 | detectSupport({ 70 | version: '2.1.0', 71 | $schema: 'https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.4.json', 72 | } as any, logsSupported, logsNotSupported); 73 | assert.strictEqual(logsSupported.length, 1); 74 | assert.strictEqual(logsNotSupported.length, 1); 75 | 76 | detectSupport({ 77 | version: '2.1.0', 78 | } as any, logsSupported, logsNotSupported); 79 | assert.strictEqual(logsSupported.length, 2); 80 | assert.strictEqual(logsNotSupported.length, 1); 81 | }); 82 | 83 | it('honors cancellation', async () => { 84 | const cancel = { isCancellationRequested: true }; 85 | const { loadLogs } = proxyquire('./loadLogs', stubs); 86 | const logs = await loadLogs(uris, cancel); 87 | assert.strictEqual(logs.length, 0); 88 | }); 89 | 90 | it('can quick upgrade if appropriate', async () => { 91 | const { tryFastUpgradeLog } = proxyquire('./loadLogs', stubs); 92 | 93 | const runs = [{ 94 | results: [{ 95 | suppressions: [{ 96 | state: 'accepted' 97 | }], 98 | }], 99 | }]; 100 | 101 | const rtm5 = { 102 | version: '2.1.0', 103 | $schema: 'https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json', 104 | runs, 105 | } as any; 106 | assert.strictEqual(await tryFastUpgradeLog(rtm5), false); 107 | 108 | const rtm4 = { 109 | version: '2.1.0', 110 | $schema: 'https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.4.json', 111 | runs, 112 | } as any; 113 | assert.strictEqual(await tryFastUpgradeLog(rtm4), true); 114 | assert.strictEqual(rtm4.runs[0].results[0].suppressions[0].status, 'accepted'); 115 | assert.strictEqual(rtm4.runs[0].results[0].suppressions[0].state, undefined); 116 | }); 117 | 118 | it('can normalize schema strings', () => { 119 | const { normalizeSchema } = proxyquire('./loadLogs', stubs); 120 | 121 | // Actual schemas from telemetry, ordered by popularity. 122 | const schemas = [ 123 | ['https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json' , 'sarif-2.1.0-rtm.5'], 124 | ['https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.4.json' , 'sarif-2.1.0-rtm.4'], 125 | ['https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json' , 'sarif-2.1.0'], 126 | ['http://json.schemastore.org/sarif-2.1.0-rtm.1' , 'sarif-2.1.0-rtm.1'], 127 | ['https://docs.oasis-open.org/sarif/sarif/v2.1.0/cos02/schemas/sarif-schema-2.1.0.json' , 'sarif-2.1.0'], 128 | ['https://json.schemastore.org/sarif-2.1.0.json' , 'sarif-2.1.0'], 129 | ['http://json.schemastore.org/sarif-2.1.0-rtm.4' , 'sarif-2.1.0-rtm.4'], 130 | ['' , ''], 131 | ['http://json.schemastore.org/sarif-1.0.0' , 'sarif-1.0.0'], 132 | ['http://json.schemastore.org/sarif-2.1.0-rtm.5' , 'sarif-2.1.0-rtm.5'], 133 | ['https://json.schemastore.org/sarif-2.1.0-rtm.5.json' , 'sarif-2.1.0-rtm.5'], 134 | ['https://schemastore.azurewebsites.net/schemas/json/sarif-1.0.0.json' , 'sarif-1.0.0'], 135 | ['http://json.schemastore.org/sarif-2.1.0-rtm.5.json' , 'sarif-2.1.0-rtm.5'], 136 | ['https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Documents/CommitteeSpecifications/2.1.0/sarif-schema-2.1.0.json' , 'sarif-2.1.0'], 137 | ['http://json.schemastore.org/sarif-2.1.0' , 'sarif-2.1.0'], 138 | ['https://docs.oasis-open.org/sarif/sarif/v2.1.0/cos02/schemas/sarif-schema-2.1.0' , 'sarif-2.1.0'], 139 | ['http://json-schema.org/draft-04/schema#' , 'schema'], 140 | ['http://json.schemastore.org/sarif-2.1.0-rtm.0' , 'sarif-2.1.0-rtm.0'], 141 | ['http://json.schemastore.org/sarif-2.1.0.json' , 'sarif-2.1.0'], 142 | ['https://www.schemastore.org/schemas/json/sarif-2.1.0-rtm.5.json' , 'sarif-2.1.0-rtm.5'], 143 | ['https://docs.oasis-open.org/sarif/sarif/v2.1.0/cos01/schemas/sarif-schema-2.1.0.json' , 'sarif-2.1.0'], 144 | ['https://docs.oasis-open.org/sarif/sarif/v2.0/csprd02/schemas/sarif-schema-2.1.0.json' , 'sarif-2.1.0'], 145 | ['https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0.json' , 'sarif-2.1.0'], 146 | ['http://docs.oasis-open.org/sarif/sarif/v2.1.0/os/schemas/sarif-schema-2.1.0.json' , 'sarif-2.1.0'], 147 | ['https://docs.oasis-open.org/sarif/sarif/v2.1.0/csprd01/schemas/sarif-schema-2.1.0.json' , 'sarif-2.1.0'], 148 | ['http://json.schemastore.org/sarif-2.0.0' , 'sarif-2.0.0'], 149 | ['https://raw.githubusercontent.com/schemastore/schemastore/master/src/schemas/json/sarif-2.1.0-rtm.5.json' , 'sarif-2.1.0-rtm.5'], 150 | ]; 151 | 152 | assert(schemas.every(([schema, normalized]) => normalizeSchema(schema) === normalized)); 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /src/extension/loadLogs.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | /// 5 | import { readFileSync } from 'fs'; 6 | import { Log, ReportingDescriptor } from 'sarif'; 7 | import { eq, gt, lt } from 'semver'; 8 | import { Uri, window, workspace } from 'vscode'; 9 | import { augmentLog } from '../shared'; 10 | import * as Telemetry from './telemetry'; 11 | 12 | export const driverlessRules = new Map(); 13 | 14 | export async function loadLogs(uris: Uri[], token?: { isCancellationRequested: boolean }) { 15 | const logs = uris 16 | .map(uri => { 17 | if (token?.isCancellationRequested) return undefined; 18 | try { 19 | const file = readFileSync(uri.fsPath, 'utf8') // Assume scheme file. 20 | .replace(/^\uFEFF/, ''); // Trim BOM. 21 | const log = JSON.parse(file) as Log; 22 | log._uri = uri.toString(); 23 | return log; 24 | } catch (error) { 25 | window.showErrorMessage(`Failed to parse '${uri.fsPath}'`); 26 | return undefined; 27 | } 28 | }) 29 | .filter(log => log) as Log[]; 30 | 31 | logs.forEach(log => Telemetry.sendLogVersion(log.version, log.$schema ?? '')); 32 | logs.forEach(tryFastUpgradeLog); 33 | 34 | const logsSupported = [] as Log[]; 35 | const logsNotSupported = [] as Log[]; 36 | const warnUpgradeExtension = logs.some(log => detectSupport(log, logsSupported, logsNotSupported)); 37 | for (const log of logsNotSupported) { 38 | if (token?.isCancellationRequested) break; 39 | const {fsPath} = Uri.parse(log._uri, true); 40 | window.showWarningMessage(`'${fsPath}' was not loaded. Version '${log.version}' and schema '${log.$schema ?? ''}' is not supported.`); 41 | } 42 | 43 | // primaryWorkspaceFolderUriString expected to be 44 | // encoded as `file:///c%3A/folder/` (toString(false /* encode */)) 45 | // and not as `file:///c:/folder/` (toString(true /* skip encode */)) 46 | const primaryWorkspaceFolderUriString = workspace.workspaceFolders?.[0]?.uri.toString(); 47 | logsSupported.forEach(log => { 48 | // Only supporting single workspaces for now. 49 | augmentLog(log, driverlessRules, primaryWorkspaceFolderUriString); 50 | }); 51 | 52 | if (warnUpgradeExtension) { 53 | window.showWarningMessage('Some log versions are newer than this extension.'); 54 | } 55 | return logsSupported; 56 | } 57 | 58 | export function normalizeSchema(schema: string): string { 59 | if (schema === '') return ''; 60 | return new URL(schema).pathname.split('/').pop() 61 | ?.replace('-schema', '') 62 | ?.replace(/\.json$/, '') 63 | ?? ''; 64 | } 65 | 66 | export function detectSupport(log: Log, logsSupported: Log[], logsNotSupported: Log[]): boolean { 67 | const {version} = log; 68 | if (!version || lt(version, '2.1.0')) { 69 | logsNotSupported.push(log); 70 | } else if (gt(version, '2.1.0')) { 71 | return true; // warnUpgradeExtension 72 | } else if (eq(version, '2.1.0')) { 73 | const normalizedSchema = normalizeSchema(log.$schema ?? ''); 74 | const supportedSchemas = [ 75 | '', 76 | 'sarif-2.1.0-rtm.6', 77 | 'sarif-2.1.0-rtm.5', 78 | 'sarif-2.1.0', // As of Aug 2020 the contents of `2.1.0` = `2.1.0-rtm.6`. Still true April 2023. 79 | ]; 80 | if (supportedSchemas.includes(normalizedSchema)) { 81 | logsSupported.push(log); 82 | } else { 83 | logsNotSupported.push(log); 84 | } 85 | } 86 | return false; 87 | } 88 | 89 | /** 90 | * Attempts to in-memory upgrade SARIF log. Only some versions (those with simple upgrades) supported. 91 | * @returns Success of the upgrade. 92 | */ 93 | export function tryFastUpgradeLog(log: Log): boolean { 94 | const { version } = log; 95 | if (!eq(version, '2.1.0')) return false; 96 | 97 | const schema = normalizeSchema(log.$schema ?? '').replace(/^sarif-/, ''); 98 | switch (schema) { 99 | case '2.1.0-rtm.1': 100 | case '2.1.0-rtm.2': 101 | case '2.1.0-rtm.3': 102 | case '2.1.0-rtm.4': 103 | applyRtm5(log); 104 | return true; 105 | case '2.1.0-rtm.6': 106 | // No impactful changes between rtm.6 and rtm.5. 107 | return true; 108 | default: 109 | return false; 110 | } 111 | } 112 | 113 | function applyRtm5(log: Log) { 114 | // Skipping upgrading inlineExternalProperties as the viewer does not use it. 115 | log.$schema = 'https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json'; 116 | log.runs?.forEach(run => { 117 | run.results?.forEach(result => { 118 | // Pre-rtm5 suppression type is different, thus casting as `any`. 119 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 120 | result.suppressions?.forEach((suppression: any) => { 121 | if (!suppression.state) return; 122 | suppression.status = suppression.state; 123 | delete suppression.state; 124 | }); 125 | }); 126 | }); 127 | } 128 | -------------------------------------------------------------------------------- /src/extension/measureDrift.md: -------------------------------------------------------------------------------- 1 | # Anti Drift 2 | 3 | ### When fetching analysis... 4 | * BEFORE: Only accept the exact match. 5 | * NOW: Accept the most recent "intersecting" match. 6 | * "Intersecting" aka common ancestor. This includes the exact. 7 | * Weakness: The intersection may be outside the page size. 8 | * Example: Headless commit from a long time ago. 9 | * Example: The remote is way ahead. 10 | 11 | ### When feeding diagnostics to VSCode... 12 | * If an 'intersecting' analysis was found, get the `sourceFile@intersectingCommit`. 13 | * Diff the intersecting source and current source. 14 | * For each result, use the diff to shift the result range. 15 | 16 | ### Diff finer details 17 | * The diff is a list of 3 types of blocks: 18 | * Unchanged, added, removed 19 | * For our purposes, we see it was two types of blocks: 20 | * Unchanged, and changed (added + removed) 21 | * These are strictly alternating. 22 | * Traverse the diff, keeping track of: 23 | * The left-side offset and right-side offset. 24 | * Optimized: Left-side offset and delta. 25 | * Left-side delta is used for "indexing" 26 | * Delta is used for shifing. 27 | * Cases... 28 | * Result falls wholly within a block 29 | * Block is unchanged: shift the result. 30 | * Block is changed: result is invalid, move to the top (or remove it). 31 | * Result spans two or more blocks 32 | * If the start/end fall within unchanged blocks, it might still be valid and shiftable. 33 | * All other cases, probably safer to invalidate ther result. 34 | -------------------------------------------------------------------------------- /src/extension/measureDrift.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import { Change } from 'diff'; 5 | 6 | export function measureDrift(diffBlocks: Change[], offsetStart: number, offsetEnd: number): number | undefined { 7 | if (diffBlocks[0]?.added || diffBlocks[0]?.removed) { 8 | diffBlocks.unshift({ value: '' }); // skipping change.count 9 | } 10 | let offsetL = 0; 11 | let offsetR = 0; 12 | for (let i = 0; i < diffBlocks.length;) { 13 | if (diffBlocks[i].added || diffBlocks[i].removed) throw new Error('Unexpected added/removed'); 14 | 15 | const drift = offsetR - offsetL; 16 | offsetL += diffBlocks[i].value.length; 17 | offsetR += diffBlocks[i].value.length; 18 | i++; 19 | if (offsetL > offsetStart) { // > or >= 20 | return offsetL > offsetEnd ? drift : undefined; 21 | } 22 | 23 | if (diffBlocks[i]?.removed) { // Left side 24 | offsetL += diffBlocks[i].value.length; 25 | i++; 26 | } 27 | if (diffBlocks[i]?.added) { // Right side 28 | offsetR += diffBlocks[i].value.length; 29 | i++; 30 | } 31 | if (offsetL > offsetStart) { // > or >= 32 | return undefined; // does not map to a changed block 33 | } 34 | } 35 | return undefined; 36 | } 37 | -------------------------------------------------------------------------------- /src/extension/platform.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | // Wrapping this for testability. 5 | export default process.platform; 6 | -------------------------------------------------------------------------------- /src/extension/platformUriNormalize.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import { Uri } from 'vscode'; 5 | import platform from './platform'; 6 | 7 | export default function(uri: Uri): Uri { 8 | if (platform === 'win32' && uri.scheme === 'file') { 9 | return Uri.parse(uri.toString().toLowerCase(), true); 10 | } 11 | return uri; 12 | } 13 | -------------------------------------------------------------------------------- /src/extension/regionToSelection.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import { Change } from 'diff'; 5 | import { Region } from 'sarif'; 6 | import { Selection } from 'vscode'; 7 | import '../shared/extension'; 8 | import { measureDrift } from './measureDrift'; 9 | import { TextDocumentLike } from './stringTextDocument'; 10 | 11 | function regionToSelection(doc: TextDocumentLike, region: Region | undefined) { 12 | if (!region) return new Selection(0, 0, 0, 0); // TODO: Decide if empty regions should be pre-filtered. 13 | 14 | const { byteOffset, startLine, charOffset } = region; 15 | 16 | if (byteOffset !== undefined) { 17 | // Assumes Hex editor view. 18 | const byteLength = region.byteLength ?? 0; 19 | const startColRaw = byteOffset % 16; 20 | const endColRaw = (byteOffset + byteLength) % 16; 21 | return new Selection( 22 | Math.floor(byteOffset / 16), 23 | 10 + startColRaw + Math.floor(startColRaw / 2), 24 | Math.floor((byteOffset + byteLength) / 16), 25 | 10 + endColRaw + Math.floor(endColRaw / 2), 26 | ); 27 | } 28 | 29 | if (startLine !== undefined) { 30 | const line = doc.lineAt(startLine - 1); 31 | 32 | // Translate from Region (1-based) to Range (0-based). 33 | const minusOne = (n: number | undefined) => n === undefined ? undefined : n - 1; 34 | 35 | return new Selection( 36 | startLine - 1, 37 | Math.max(line.firstNonWhitespaceCharacterIndex, minusOne(region.startColumn) ?? 0), // Trim leading whitespace. 38 | (region.endLine ?? startLine) - 1, 39 | minusOne(region.endColumn) ?? line.range.end.character, 40 | ); 41 | } 42 | 43 | if (charOffset !== undefined) { 44 | return new Selection( 45 | doc.positionAt(charOffset), 46 | doc.positionAt(charOffset + (region.charLength ?? 0)) 47 | ); 48 | } 49 | 50 | return new Selection(0, 0, 0, 0); // Technically an invalid region, but no use complaining to the user. 51 | } 52 | 53 | export function driftedRegionToSelection(diffBlocks: Change[], currentDoc: TextDocumentLike, region: Region | undefined, originalDoc?: TextDocumentLike) { 54 | // If there is no originalDoc, the best we can do is hope no drift has occurred since the scan. 55 | if (originalDoc === undefined) return regionToSelection(currentDoc, region); 56 | 57 | const originalRange = regionToSelection(originalDoc, region); 58 | if (originalRange.isReversed) console.warn('REVERSED'); 59 | 60 | const drift = measureDrift( 61 | diffBlocks, 62 | originalDoc.offsetAt(originalRange.start), 63 | originalDoc.offsetAt(originalRange.end), 64 | ); 65 | return drift === undefined 66 | ? new Selection( 67 | currentDoc.positionAt(0), 68 | currentDoc.positionAt(0) 69 | ) 70 | : new Selection( 71 | currentDoc.positionAt(originalDoc.offsetAt(originalRange.start) + drift), 72 | currentDoc.positionAt(originalDoc.offsetAt(originalRange.end) + drift) 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /src/extension/resultDiagnostic.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import { Result } from 'sarif'; 5 | import { Diagnostic, DiagnosticSeverity, Range } from 'vscode'; 6 | 7 | export class ResultDiagnostic extends Diagnostic { 8 | constructor(range: Range, message: string, severity: DiagnosticSeverity, readonly result: Result) { 9 | super(range, message, severity); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/extension/statusBarItem.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import { StatusBarAlignment, StatusBarItem, window } from 'vscode'; 5 | import { observable, observe } from 'mobx'; 6 | 7 | let statusBarItem: StatusBarItem | undefined; 8 | 9 | export const isSpinning = observable.box(false); 10 | function getStatusText() { 11 | return `$(${isSpinning.get() ? 'sync~spin' : 'shield' }) Sarif`; 12 | } 13 | 14 | export function activateSarifStatusBarItem(disposables: { dispose(): void }[]): void { 15 | if (statusBarItem) return; 16 | 17 | statusBarItem = window.createStatusBarItem(StatusBarAlignment.Left); 18 | disposables.push(statusBarItem); 19 | statusBarItem.text = getStatusText(); 20 | statusBarItem.command = 'sarif.showPanel'; 21 | statusBarItem.tooltip ='Show SARIF Panel'; 22 | statusBarItem.show(); 23 | 24 | observe(isSpinning, () => statusBarItem!.text = getStatusText()); 25 | } 26 | -------------------------------------------------------------------------------- /src/extension/store.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import { computed, IArrayWillSplice, intercept, observable } from 'mobx'; 5 | import { Log } from 'sarif'; 6 | import { Memento } from 'vscode'; 7 | import { mapDistinct } from '../shared'; 8 | import '../shared/extension'; 9 | import { AnalysisInfosForCommit } from './index.activateGithubAnalyses'; 10 | 11 | export class Store { 12 | static globalState: Memento 13 | 14 | @observable banner = ''; 15 | 16 | @observable.shallow logs = [] as Log[] 17 | @observable resultsFixed = [] as string[] // JSON string of ResultId. TODO: Migrate to set. 18 | @computed get results() { 19 | const runs = this.logs.map(log => log.runs).flat(); 20 | return runs.map(run => run.results ?? []).flat() 21 | .filter(result => !this.resultsFixed.includes(JSON.stringify(result._id))); 22 | } 23 | @computed get distinctArtifactNames() { 24 | const fileAndUris = this.logs.map(log => [...log._distinct.entries()]).flat(); 25 | return mapDistinct(fileAndUris); 26 | } 27 | 28 | public disableSelectionSync = false; 29 | public branch = '' 30 | public commitHash = '' 31 | @observable.shallow analysisInfo: AnalysisInfosForCommit | undefined 32 | @observable remoteAnalysisInfoUpdated = 0 // A version number as a substitute for a value-less observable. 33 | 34 | constructor() { 35 | intercept(this.logs, objChange => { 36 | const change = objChange as unknown as IArrayWillSplice; 37 | change.added = change.added.filter(log => this.logs.every(existing => existing._uri !== log._uri)); 38 | return objChange; 39 | }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/extension/stringTextDocument.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import { Position } from 'vscode'; 5 | 6 | function getOffset(text: string, position: Position): number { 7 | let line = 0; 8 | for (let i = 0; i < text.length; i++) { 9 | if (line === position.line) { 10 | return i + position.character; 11 | } 12 | 13 | const ch = text[i]; 14 | if (ch === '\n') line++; 15 | } 16 | return text.length; // Right design? 17 | } 18 | 19 | interface TextLineLike { 20 | firstNonWhitespaceCharacterIndex: number; 21 | range: { end: { character: number } }; 22 | } 23 | 24 | export interface TextDocumentLike { 25 | lineAt(line: number): TextLineLike; 26 | positionAt(offset: number): Position; 27 | offsetAt(position: Position): number; 28 | } 29 | 30 | // A TextDocument-like object backed by a string rather than a file on disk. 31 | export class StringTextDocument implements TextDocumentLike { 32 | constructor(readonly text: string) {} 33 | 34 | getText() { 35 | return this.text; 36 | } 37 | 38 | private _lines: undefined | string[]; 39 | private get lines(): string[] { 40 | if (!this._lines) { 41 | this._lines = this.text.split(/\r?\n/g); 42 | } 43 | return this._lines; 44 | } 45 | 46 | lineAt(line: number): TextLineLike { 47 | const lineText = this.lines[line]; 48 | return { 49 | firstNonWhitespaceCharacterIndex: lineText.search(/\S|$/), 50 | range: { end: { character: lineText.length } }, 51 | }; 52 | } 53 | 54 | positionAt(_offset: number): Position { 55 | // Reserved for charOffset+charLength which we currently do not support. 56 | return new Position(0, 0); 57 | } 58 | 59 | offsetAt(position: Position) { 60 | return getOffset(this.text, position); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/extension/telemetry.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import TelemetryReporter from 'vscode-extension-telemetry'; 5 | import { publisher, name, version } from '../../package.json'; 6 | 7 | let reporter: TelemetryReporter; 8 | 9 | export function activate() { 10 | const key = 'bf8e52c4-6749-4709-92a0-e3a8fd589648'; 11 | reporter = new TelemetryReporter(`${publisher}.${name}`, version, key); 12 | } 13 | 14 | export function deactivate() { 15 | reporter?.dispose(); 16 | } 17 | 18 | export function sendLogVersion(version: string, $schema: string) { 19 | reporter?.sendTelemetryEvent('logVersion', { version, $schema }); 20 | } 21 | 22 | export function sendGithubEligibility(eligibility: string) { 23 | reporter?.sendTelemetryEvent('githubEligibility', { eligibility }); 24 | } 25 | 26 | export function sendGithubPromptChoice(choice: string | undefined) { 27 | reporter?.sendTelemetryEvent('githubPromptChoice', { choice: choice ?? 'undefined' }); 28 | } 29 | 30 | export function sendGithubAnalysisFound(value: string) { 31 | reporter?.sendTelemetryEvent('githubAnalysisFound', { choice: value }); 32 | } 33 | 34 | export function sendGithubConfig(value: string) { 35 | reporter?.sendTelemetryEvent('githubConfig', { value }); 36 | } 37 | 38 | export function sendGithubAutofixApplied(status: 'success' | 'failure', message = '') { 39 | reporter?.sendTelemetryEvent('githubAutofixApplied', { status, message }); 40 | } 41 | -------------------------------------------------------------------------------- /src/extension/update.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | /* eslint-disable @typescript-eslint/no-explicit-any */ // Allowing any for mocks. 5 | 6 | import assert from 'assert'; 7 | import { fake } from 'sinon'; 8 | 9 | const proxyquire = require('proxyquire').noCallThru(); 10 | 11 | // https://api.github.com/repos/Microsoft/sarif-vscode-extension/releases 12 | // Releases typically ordered most recent first. 13 | const releases = [ 14 | { 15 | 'tag_name': 'v3.0.1-0', 16 | 'assets_url': 'https://api.github.com/repos/microsoft/sarif-vscode-extension/releases/26068659/assets', 17 | 'assets': [ 18 | { 19 | 'url': 'https://api.github.com/repos/microsoft/sarif-vscode-extension/releases/assets/20315654', 20 | 'name': 'MS-SarifVSCode.sarif-viewer.vsix', 21 | 'label': '', 22 | 'content_type': 'application/vsix', 23 | 'state': 'uploaded', 24 | 'size': 112615432, 25 | 'browser_download_url': 'https://github.com/microsoft/sarif-vscode-extension/releases/download/v3.2020.430009-insiders/MS-SarifVSCode.sarif-viewer.vsix' 26 | } 27 | ] 28 | }, 29 | { 30 | 'tag_name': 'v3.0.0', // installedVersion 31 | 'assets_url': 'https://api.github.com/repos/microsoft/sarif-vscode-extension/releases/25973815/assets' 32 | }, 33 | { 34 | 'tag_name': 'v3.0.0-0', 35 | 'assets_url': 'https://api.github.com/repos/microsoft/sarif-vscode-extension/releases/25964127/assets' 36 | }, 37 | { 38 | 'tag_name': 'v2.15.0', 39 | 'assets_url': 'https://api.github.com/repos/microsoft/sarif-vscode-extension/releases/19035869/assets' 40 | }, 41 | { 42 | 'tag_name': 'v2.0.1', 43 | 'assets_url': 'https://api.github.com/repos/microsoft/sarif-vscode-extension/releases/11127038/assets' 44 | }, 45 | { 46 | 'tag_name': 'v2.0.0', 47 | 'assets_url': 'https://api.github.com/repos/microsoft/sarif-vscode-extension/releases/11125442/assets' 48 | }, 49 | { 50 | 'tag_name': 'v1.0.0', 51 | 'assets_url': 'https://api.github.com/repos/microsoft/sarif-vscode-extension/releases/10719743/assets' 52 | } 53 | ]; 54 | 55 | const makeStubs = () => ({ 56 | 'follow-redirects': { 57 | https: { 58 | get: ( 59 | _options: Record, 60 | callback?: (res: any) => void 61 | ) => { 62 | const listeners = {} as Record; 63 | callback?.({ 64 | statusCode: 200, 65 | pipe: () => undefined, 66 | on: (event: string, listener: any) => listeners[event] = listener 67 | }); 68 | listeners['end']?.(); 69 | } 70 | }, 71 | }, 72 | 'node-fetch': async (url: string) => { 73 | if (url.endsWith('releases')) 74 | return { status: 200, json: async () => releases }; 75 | return undefined; 76 | }, 77 | 'vscode': { 78 | commands: { 79 | executeCommand: fake() // (command: string, ...rest: any[]) => undefined 80 | }, 81 | extensions: { 82 | getExtension: () => ({ packageJSON: { version: '3.0.0' } }) 83 | }, 84 | Uri: { 85 | file: (path: string) => ({ path }) 86 | }, 87 | window: { 88 | showInformationMessage: async (_msg: string, ...items: string[]) => items[0] 89 | }, 90 | workspace: { 91 | getConfiguration: () => ({ get: () => 'Insiders' }) 92 | }, 93 | }, 94 | }); 95 | 96 | describe('update', () => { 97 | it('updates', async () => { 98 | const stubs = makeStubs(); 99 | const { update } = proxyquire('./update', stubs); 100 | assert.strictEqual(await update(), true); 101 | assert.strictEqual(stubs['vscode'].commands.executeCommand.callCount, 2); 102 | }); 103 | 104 | it('does not update, if already up to date', async () => { 105 | const stubs = makeStubs(); 106 | stubs['vscode'].extensions.getExtension = () => ({ packageJSON: { version: '3.0.1-0' } }); 107 | const { update } = proxyquire('./update', stubs); 108 | assert.strictEqual(await update(), false); 109 | }); 110 | 111 | it('does not update, update channel is not "Insiders"', async () => { 112 | const stubs = makeStubs(); 113 | stubs['follow-redirects']; 114 | stubs['vscode'].workspace.getConfiguration = () => ({ get: () => 'Default' }); 115 | const { update } = proxyquire('./update', stubs); 116 | assert.strictEqual(await update(), false); 117 | }); 118 | 119 | it('does not update, if already updating', async () => { 120 | const { update } = proxyquire('./update', makeStubs()); 121 | void update(); // If this update is in progress (no await), the 2nd should block. 122 | assert.strictEqual(await update(), false); 123 | }); 124 | 125 | it('does not forget to clear the updateInProgress flag', async () => { 126 | const { update } = proxyquire('./update', makeStubs()); 127 | await update(); // Wait for the first one to finish, then 2nd should work. 128 | assert.strictEqual(await update(), true); 129 | }); 130 | 131 | it('gracefully handles network failure', async () => { 132 | const stubs = makeStubs(); 133 | stubs['node-fetch'] = async () => { throw Error(); }; 134 | const { update } = proxyquire('./update', stubs); 135 | assert.strictEqual(await update(), false); 136 | 137 | // Make sure updateInProgress isn't stuck on true after failure. 138 | const { update: updateAgain } = proxyquire('./update', makeStubs()); 139 | assert.strictEqual(await updateAgain(), true); 140 | }); 141 | 142 | it('gracefully handles GitHub rate limit exceeded', async () => { 143 | const stubs = makeStubs(); 144 | stubs['node-fetch'] = async () => ({ 145 | status: 403, 146 | statusText: 'rate limit exceeded' 147 | } as any); 148 | const { update } = proxyquire('./update', stubs); 149 | assert.strictEqual(await update(), false); 150 | }); 151 | 152 | it('gracefully handles download forbidden', async () => { 153 | const stubs = makeStubs(); 154 | stubs['follow-redirects'].https.get = ( 155 | _options: Record, 156 | callback?: (res: any) => void 157 | ) => { 158 | callback?.({ statusCode: 403 }); 159 | }; 160 | const { update } = proxyquire('./update', stubs); 161 | assert.strictEqual(await update(), false); 162 | }); 163 | 164 | it('gracefully handles download failure', async () => { 165 | const stubs = makeStubs(); 166 | stubs['follow-redirects'].https.get = () => ({ 167 | on: (_event: 'error', listener: any) => listener() 168 | }); 169 | const { update } = proxyquire('./update', stubs); 170 | assert.strictEqual(await update(), false); 171 | }); 172 | 173 | // TODO: it('respects proxy settings', async () => {}) 174 | }); 175 | -------------------------------------------------------------------------------- /src/extension/update.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import { https as redirectableHttps } from 'follow-redirects'; 5 | import fs from 'fs'; 6 | import { HttpsProxyAgent } from 'https-proxy-agent'; 7 | import fetch from 'node-fetch'; 8 | import { gt } from 'semver'; 9 | import { tmpNameSync } from 'tmp'; 10 | import { parse as urlParse } from 'url'; 11 | import { commands, extensions, Uri, window, workspace } from 'vscode'; 12 | 13 | interface GitHubAsset { 14 | content_type: string; 15 | browser_download_url: string; 16 | } 17 | 18 | interface GitHubRelease { 19 | tag_name: string; 20 | assets: GitHubAsset[]; 21 | } 22 | 23 | /** 24 | * Retrieves @see HttpsProxyAgent information that may be setup in VSCode or in the process environment 25 | * to use for HTTP(s) requests. 26 | */ 27 | function getHttpsProxyAgent() { 28 | // See if we have an HTTP proxy set up in VSCode's proxy settings or 29 | // if it has been set up in the process environment. 30 | // NOTE: The upper and lower case versions are best attempt effort as enumerating through 31 | // all the environment key\value pairs to perform case insensitive compares for "http(s)_proxy" 32 | // would be a bit too much. 33 | const proxy = workspace.getConfiguration().get('http.proxy', undefined) || 34 | process.env.HTTPS_PROXY || 35 | process.env.https_proxy || 36 | process.env.HTTP_PROXY || 37 | process.env.http_proxy; 38 | if (!proxy) { // If no proxy is defined, we're done 39 | return undefined; 40 | } 41 | 42 | const { protocol, port, host, auth } = urlParse(proxy); // TODO: Consider migrating to to URL? 43 | if (!protocol || (protocol !== 'https:' && protocol !== 'http:')) { 44 | return undefined; 45 | } 46 | 47 | return new HttpsProxyAgent({ 48 | port: port && +port, 49 | host: host, 50 | auth: auth, 51 | secureProxy: workspace.getConfiguration().get('http.proxyStrictSSL', true) 52 | }); 53 | } 54 | 55 | export const updateChannelConfigSection = 'updateChannel'; 56 | const extensionName = 'sarif-viewer'; 57 | let updateInProgress = false; 58 | 59 | function isUpdateEnabled() { 60 | const updateChannel = workspace.getConfiguration(extensionName).get(updateChannelConfigSection, 'Default'); 61 | return updateChannel === 'Insiders'; 62 | } 63 | 64 | /** Determine if a newer version of this extension exists and install it. Useful for off-marketplace release channels. */ 65 | // TODO: Handle/test http proxies. 66 | export async function update() { 67 | if (updateInProgress) return false; 68 | updateInProgress = true; 69 | if (!isUpdateEnabled()) return false; 70 | 71 | const extensionFullName = `MS-SarifVSCode.${extensionName}`; 72 | const installedVersion = extensions.getExtension(extensionFullName)!.packageJSON.version; 73 | const agent = getHttpsProxyAgent(); 74 | 75 | const success = await (async () => { 76 | try { 77 | // 1) Find the right release from the list. 78 | const releasesResponse = await fetch('https://api.github.com/repos/Microsoft/sarif-vscode-extension/releases', { agent }); 79 | if (releasesResponse.status !== 200) return false; 80 | const releases = await releasesResponse.json() as GitHubRelease[]; 81 | const release = releases.find(release => gt(release.tag_name, installedVersion)); 82 | if (!release) return false; 83 | 84 | // 2) Find the right asset from the release assets. 85 | // Our releases only contain a single VSIX. Thus we assume the first one is the correct one. 86 | const asset = release.assets.find(asset => asset.content_type === 'application/vsix'); 87 | if (!asset) return false; 88 | 89 | // 3) Download the VSIX to temp. 90 | const url = new URL(asset.browser_download_url); 91 | const vsixFile = tmpNameSync({ postfix: '.vsix' }); 92 | const stream = fs.createWriteStream(vsixFile); 93 | await new Promise((resolve, reject) => { 94 | const request = redirectableHttps.get({ // Only browser_download_url seems to have redirects. Otherwise would use fetch. 95 | hostname: url.hostname, 96 | path: url.pathname, 97 | headers: { 'User-Agent': `microsoft.${extensionName}` }, 98 | agent, 99 | }, response => { 100 | if (response.statusCode !== 200) reject(); 101 | response.pipe(stream); 102 | response.on('end', resolve); 103 | }); 104 | request.on('error', reject); 105 | }); 106 | 107 | // 4) Install the VSIX, unless the user decides not to. 108 | // The user can change the "update channel" setting during the download. Thus, we need to re-confirm. 109 | if (!isUpdateEnabled()) return false; 110 | await commands.executeCommand('workbench.extensions.installExtension', Uri.file(vsixFile)); 111 | const response = await window.showInformationMessage( 112 | `A new version of the SARIF Viewer (${release.tag_name}) has been installed. Reload to take affect.`, 113 | 'Reload now' 114 | ); 115 | if (response) { 116 | await commands.executeCommand('workbench.action.reloadWindow'); 117 | } 118 | return true; 119 | } catch (error) { 120 | return false; 121 | } 122 | })(); 123 | 124 | updateInProgress = false; 125 | return success; 126 | } 127 | -------------------------------------------------------------------------------- /src/extension/uriExists.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import { Uri, workspace } from 'vscode'; 5 | 6 | // Hacky: We are using `fs.stat` to test the existence of documents as VS Code does not provide a dedicated existence API. 7 | // The similar Node `fs` API does not resolve custom URI schemes in the same way that VS Code does otherwise we would use that. 8 | export default async function uriExists(absoluteUri: Uri) { 9 | try { 10 | await workspace.fs.stat(absoluteUri); 11 | } catch (error) { 12 | return false; 13 | } 14 | return true; 15 | } 16 | -------------------------------------------------------------------------------- /src/extension/uriRebaser.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | /* eslint-disable no-throw-literal */ // Can be removed when we move to vscode.workspace.fs. 5 | 6 | import assert from 'assert'; 7 | import { URI as Uri } from 'vscode-uri'; 8 | import '../shared/extension'; 9 | import { mockVscode, mockVscodeTestFacing } from '../test/mockVscode'; 10 | 11 | const proxyquire = require('proxyquire').noCallThru(); 12 | 13 | describe('baser', () => { 14 | const platformUriNormalize = proxyquire('./platformUriNormalize', { 15 | 'vscode': { Uri }, 16 | './platform': 'linux', 17 | }); 18 | 19 | it('translates uris - local -> artifact - case-insensitive file system', async () => { 20 | // Spaces inserted to emphasize common segments. 21 | const artifactUri = 'file:// /a/b'.replace(/ /g, ''); 22 | const localUri = 'file:// /a/B'.replace(/ /g, ''); 23 | const platformUriNormalize = proxyquire('./platformUriNormalize', { 24 | 'vscode': { Uri }, 25 | './platform': 'win32', 26 | }); 27 | const { UriRebaser } = proxyquire('./uriRebaser', { 28 | 'vscode': { 29 | '@global': true, 30 | ...mockVscode, 31 | }, 32 | './platformUriNormalize': platformUriNormalize, 33 | './uriExists': () => { throw new Error(); }, 34 | }); 35 | const distinctArtifactNames = new Map([ 36 | [artifactUri.file, artifactUri] 37 | ]); 38 | 39 | // Need to restructure product+test to better simulate the calculation distinctLocalNames. 40 | const rebaser = new UriRebaser({ distinctArtifactNames }); 41 | assert.strictEqual(await rebaser.translateLocalToArtifact(Uri.parse(localUri)), artifactUri); 42 | }); 43 | 44 | it('translates uris - local -> artifact - case-sensitive file system (lowercase)', async () => { 45 | // Spaces inserted to emphasize common segments. 46 | const artifactUri = 'file:// /a/b'.replace(/ /g, ''); 47 | const localUri = 'file:// /a/b'.replace(/ /g, ''); 48 | const { UriRebaser } = proxyquire('./uriRebaser', { 49 | 'vscode': { 50 | '@global': true, 51 | ...mockVscode, 52 | }, 53 | './platformUriNormalize': platformUriNormalize, 54 | './uriExists': () => { throw new Error(); }, 55 | }); 56 | const distinctArtifactNames = new Map([ 57 | [artifactUri.file, artifactUri] 58 | ]); 59 | const rebaser = new UriRebaser({ distinctArtifactNames }); 60 | assert.strictEqual(await rebaser.translateLocalToArtifact(localUri), artifactUri); 61 | }); 62 | 63 | it('translates uris - local -> artifact - case-sensitive file system (uppercase)', async () => { 64 | // Spaces inserted to emphasize common segments. 65 | const artifactUri = 'file:// /a/B'.replace(/ /g, ''); 66 | const localUri = 'file:// /a/B'.replace(/ /g, ''); 67 | const { UriRebaser } = proxyquire('./uriRebaser', { 68 | 'vscode': { 69 | '@global': true, 70 | ...mockVscode, 71 | }, 72 | './platformUriNormalize': platformUriNormalize, 73 | './uriExists': () => { throw new Error(); }, 74 | }); 75 | const distinctArtifactNames = new Map([ 76 | [artifactUri.file, artifactUri] 77 | ]); 78 | const rebaser = new UriRebaser({ distinctArtifactNames }); 79 | assert.strictEqual(await rebaser.translateLocalToArtifact(localUri), artifactUri); 80 | }); 81 | 82 | it('Distinct 1', async () => { 83 | // Spaces inserted to emphasize common segments. 84 | const artifactUri = 'file:///folder /file1.txt'.replace(/ /g, ''); 85 | const localUri = 'file:///projects/project /file1.txt'.replace(/ /g, ''); 86 | const { UriRebaser } = proxyquire('./uriRebaser', { 87 | 'vscode': { 88 | '@global': true, 89 | ...mockVscode, 90 | }, 91 | './platformUriNormalize': platformUriNormalize, 92 | './uriExists': (uri: string) => uri.toString() === localUri, 93 | }); 94 | const distinctArtifactNames = new Map([ 95 | ['file1.txt', artifactUri] 96 | ]); 97 | const rebaser = new UriRebaser({ distinctArtifactNames }); 98 | const rebasedArtifactUri = await rebaser.translateArtifactToLocal(artifactUri); 99 | assert.strictEqual(rebasedArtifactUri.toString(), localUri); // Should also match file1? 100 | }); 101 | 102 | it('Picker 1', async () => { 103 | // Spaces inserted to emphasize common segments. 104 | const artifactUri = 'file:// /a/file.txt'.replace(/ /g, ''); 105 | const localUri = 'file:///x/y/a/file.txt'.replace(/ /g, ''); 106 | mockVscodeTestFacing.showOpenDialogResult = [Uri.parse(localUri)]; 107 | const { UriRebaser } = proxyquire('./uriRebaser', { 108 | 'vscode': { 109 | '@global': true, 110 | ...mockVscode, 111 | }, 112 | './platformUriNormalize': platformUriNormalize, 113 | './uriExists': (uri: string) => uri.toString() === localUri, 114 | }); 115 | const rebaser = new UriRebaser({ distinctArtifactNames: new Map() }); 116 | const rebasedArtifactUri = await rebaser.translateArtifactToLocal(artifactUri); 117 | assert.strictEqual(rebasedArtifactUri.toString(), localUri); 118 | }); 119 | 120 | it('Picker 2', async () => { 121 | // Spaces inserted to emphasize common segments. 122 | const artifact = 'file:///d/e/f/x/y/a/b.c'.replace(/ /g, ''); 123 | const localUri = 'file:// /x/y/a/b.c'.replace(/ /g, ''); 124 | mockVscodeTestFacing.showOpenDialogResult = [Uri.parse(localUri)]; 125 | 126 | const { UriRebaser } = proxyquire('./uriRebaser', { 127 | 'vscode': { 128 | '@global': true, 129 | ...mockVscode, 130 | }, 131 | './platformUriNormalize': platformUriNormalize, 132 | './uriExists': (uri: string) => uri.toString() === localUri, 133 | }); 134 | const rebaser = new UriRebaser({ distinctArtifactNames: new Map() }); 135 | const rebasedArtifactUri = await rebaser.translateArtifactToLocal(artifact); 136 | assert.strictEqual(rebasedArtifactUri.toString(), localUri); 137 | }); 138 | 139 | it('API-injected baseUris - None, No Match', async () => { 140 | const artifactUri = 'http:///a/b/c/d.e'.replace(/ /g, ''); 141 | 142 | const { UriRebaser } = proxyquire('./uriRebaser', { 143 | 'vscode': { 144 | '@global': true, 145 | ...mockVscode, 146 | }, 147 | './platformUriNormalize': platformUriNormalize, 148 | './uriExists': (_uri: string) => false, 149 | }); 150 | const rebaser = new UriRebaser({ distinctArtifactNames: new Map() }); 151 | const rebasedArtifactUri = await rebaser.translateArtifactToLocal(artifactUri); 152 | assert.strictEqual(rebasedArtifactUri, undefined); 153 | }); 154 | 155 | it('API-injected baseUris - Typical', async () => { 156 | // Spaces inserted to emphasize common segments. 157 | const artifactUri = 'http:///a /b /c/d.e'.replace(/ /g, ''); 158 | const uriBase = 'file:///x/y /b /z '.replace(/ /g, ''); 159 | const localUri = 'file:///x/y /b /c/d.e'.replace(/ /g, ''); 160 | mockVscodeTestFacing.showOpenDialogResult = [Uri.parse(localUri)]; 161 | 162 | const { UriRebaser } = proxyquire('./uriRebaser', { 163 | 'vscode': { 164 | '@global': true, 165 | ...mockVscode, 166 | }, 167 | './platformUriNormalize': platformUriNormalize, 168 | './uriExists': (uri: string) => uri.toString() === localUri, 169 | }); 170 | const rebaser = new UriRebaser({ distinctArtifactNames: new Map() }); 171 | rebaser.uriBases = [uriBase]; 172 | const rebasedArtifactUri = await rebaser.translateArtifactToLocal(artifactUri); 173 | assert.strictEqual(rebasedArtifactUri.toString(), localUri); 174 | }); 175 | 176 | it('API-injected baseUris - Short', async () => { 177 | // Spaces inserted to emphasize common segments. 178 | const artifactUri = 'http:// /a/b'.replace(/ /g, ''); 179 | const uriBase = 'file:// /a '.replace(/ /g, ''); 180 | const localUri = 'file:// /a/b'.replace(/ /g, ''); 181 | mockVscodeTestFacing.showOpenDialogResult = [Uri.parse(localUri)]; 182 | 183 | const { UriRebaser } = proxyquire('./uriRebaser', { 184 | 'vscode': { 185 | '@global': true, 186 | ...mockVscode, 187 | }, 188 | './platformUriNormalize': platformUriNormalize, 189 | './uriExists': (uri: string) => uri.toString() === localUri, 190 | }); 191 | const rebaser = new UriRebaser({ distinctArtifactNames: new Map() }); 192 | rebaser.uriBases = [uriBase]; 193 | const rebasedArtifactUri = await rebaser.translateArtifactToLocal(artifactUri); 194 | assert.strictEqual(rebasedArtifactUri.toString(), localUri); 195 | }); 196 | }); 197 | -------------------------------------------------------------------------------- /src/panel/details.layouts.tsx: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import { observable } from 'mobx'; 5 | import * as React from 'react'; 6 | import { Component } from 'react'; 7 | import { Result } from 'sarif'; 8 | import { Details } from './details'; 9 | 10 | // A development tool to see `Details` in multiple layout states at once. 11 | export class DetailsLayouts extends Component { 12 | render() { 13 | const result: Result = { 14 | ruleId: 'DEMO01', 15 | message: { text: 'A result' }, 16 | stacks: [], 17 | _log: { _uri: 'file:///demo.sarif' }, 18 | _message: 'A result', 19 | _run: {}, 20 | } as unknown as Result; 21 | 22 | return
23 |
24 |
25 |
26 |
27 |
; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/panel/details.scss: -------------------------------------------------------------------------------- 1 | .svDetailsPane { 2 | box-sizing: border-box; 3 | flex: 0 0 auto; 4 | display: grid; 5 | grid-template-rows: auto 1fr; 6 | 7 | .svDetailsBody { 8 | overflow-y: overlay; 9 | 10 | &.svDetailsInfo { 11 | padding: 12px 22px; 12 | 13 | .svDetailsMessage { 14 | margin-bottom: 16px; 15 | white-space: pre-line; 16 | } 17 | 18 | .svDetailsGrid { 19 | display: grid; 20 | grid-template-columns: 130px auto; 21 | grid-column-gap: 4px; 22 | grid-row-gap: 4px; 23 | justify-items: start; 24 | white-space: pre-line; 25 | 26 | & > * { 27 | &:nth-child(odd) { 28 | width: 100%; // For ellipsis. 29 | color: rgb(139, 139, 139); // svSecondary. 30 | } 31 | } 32 | 33 | .svDetailsGridLocations { 34 | display: flex; 35 | flex-direction: column; 36 | *:not(:first-child) { // Rhythm. 37 | margin-top: 4px; 38 | } 39 | } 40 | } 41 | } 42 | 43 | &.svDetailsCodeflowAndStacks { 44 | display: grid; 45 | 46 | & > .svList { 47 | padding: 12px 0px; 48 | scroll-padding: 12px 0; 49 | overflow-x: hidden; 50 | overflow-y: overlay; 51 | 52 | & > .svListItem { 53 | padding: 0 22px; 54 | display: flex; 55 | align-items: center; 56 | white-space: nowrap; 57 | 58 | & > * + * { margin-left: 6px; } // Standard 59 | & > :first-child { flex: 1 1; } 60 | 61 | .svLineNum { 62 | line-height: 16px; 63 | padding: 0 4px; 64 | border-radius: 3px; 65 | background-color: var(--vscode-badge-background); 66 | color: var(--vscode-badge-foreground); 67 | } 68 | } 69 | 70 | & { 71 | & > :hover { background-color: var(--vscode-list-hoverBackground); } 72 | } 73 | &.svSelected { 74 | & > .svItemSelected { background: var(--vscode-list-inactiveSelectionBackground); } 75 | } 76 | &.svSelected:focus { 77 | outline: none !important; 78 | & > .svItemSelected { background: var(--vscode-list-activeSelectionBackground); } 79 | } 80 | } 81 | } 82 | 83 | .svStack { 84 | margin-top: 10px; 85 | } 86 | 87 | .svStacksMessage { 88 | padding-left: 22px; 89 | margin-bottom: -10px; 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/panel/detailsFeedback.scss: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | .svDetailsFeedback { 5 | padding: 12px 22px; // Same as svDetailsInfo 6 | overflow-x: overlay; 7 | 8 | .svFeedbackColumns { 9 | columns: 2 260px; 10 | } 11 | 12 | .svDetailsSection { 13 | margin-bottom: 12px; 14 | break-inside: avoid; 15 | } 16 | 17 | .svDetailsTitle { // Similar to svPopoverTitle 18 | color: var(--vscode-settings-headerForeground); 19 | font-weight: 600; 20 | margin-bottom: 4px; 21 | } 22 | 23 | textarea { // Similar to svFilterCombo input 24 | width: 100%; 25 | min-height: 80px; 26 | background-color: var(--vscode-input-background); 27 | color: var(--vscode-input-foreground); 28 | border: none; 29 | padding: 4px 5px; 30 | font-size: 12px; 31 | overflow: hidden; 32 | resize: vertical; 33 | margin: 3px 0; 34 | } 35 | 36 | input[type=button] { // Similar to svZeroData 37 | color: var(--vscode-button-foreground); 38 | background: var(--vscode-button-background); 39 | width: 180px; 40 | padding: 4px; 41 | line-height: 1.4em; 42 | text-align: center; 43 | cursor: pointer; 44 | 45 | border: none; 46 | font-size: inherit; 47 | margin-top: 12px; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/panel/detailsFeedback.tsx: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import { observable } from 'mobx'; 5 | import { observer } from 'mobx-react'; 6 | import * as React from 'react'; 7 | import { Component } from 'react'; 8 | import { Visibility } from '../shared'; 9 | import './detailsFeedback.scss'; 10 | import { Checkrow } from './widgets'; 11 | 12 | @observer export class DetailsFeedback extends Component { 13 | @observable feedbackTags: Record = { 14 | 'Useful (#useful)': false, 15 | 'False (#falsepositive)': false, 16 | 'Not Actionable (#notactionable)': false, 17 | 'Low Value (#lowvalue)': false, 18 | 'Code Does Not Ship (#doesnotship)': false, 19 | '3rd Party/OSS Code (#3rdpartycode)': false, 20 | 'Feature Request (#featurerequest)': false, 21 | 'Other (#other)': false, 22 | } 23 | render() { 24 | const {feedbackTags} = this; 25 | return
26 |
27 |
28 |
Feedback Tags
29 |
{/* rename class */} 30 | {Object.keys(feedbackTags).map(name => )} 31 |
32 |
33 |
34 |
More Details
35 | 37 | 40 | 43 | 46 | 47 |
48 |
49 |
; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/panel/filterKeywordContext.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import { createContext } from 'react'; 5 | 6 | export const FilterKeywordContext = createContext(''); 7 | -------------------------------------------------------------------------------- /src/panel/global.d.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | /* eslint-disable @typescript-eslint/no-explicit-any */ // Type info not available for some external libs. 5 | 6 | export {}; 7 | 8 | declare global { 9 | // Disagree with Typescript built-in typing `indexOf()`. It does not allow `searchElement` undefined. 10 | interface Array { 11 | indexOf(searchElement: T | undefined, fromIndex?: number | undefined): number; 12 | } 13 | interface ReadonlyArray { 14 | indexOf(searchElement: T | undefined, fromIndex?: number | undefined): number; 15 | } 16 | 17 | interface VsCodeApi { 18 | /** 19 | * Post message back to vscode extension. 20 | */ 21 | postMessage(msg: any): void; 22 | } 23 | 24 | const vscode: VsCodeApi; 25 | 26 | namespace NodeJS { 27 | interface Global { 28 | vscode: any; // VS Code does not provide type info. 29 | fetch(input: RequestInfo, init?: RequestInit): Promise; // Only used in mock. 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/panel/index.scss: -------------------------------------------------------------------------------- 1 | div, input { box-sizing: border-box; } 2 | 3 | html { 4 | height: 100%; 5 | } 6 | 7 | body { 8 | height: 100%; 9 | padding: 0; /* Override */ 10 | display: flex; 11 | flex-direction: column; 12 | margin-left: 1px; // VS Code Webview splitter eats an extra pixel on the left. 13 | } 14 | 15 | a { 16 | text-decoration: none; 17 | &:hover { text-decoration: underline; } 18 | } 19 | 20 | ::placeholder { 21 | color: var(--vscode-input-placeholderForeground); 22 | } 23 | 24 | mark { 25 | color: unset; 26 | background: var(--vscode-editor-findMatchHighlightBackground); 27 | } 28 | 29 | *:focus { 30 | outline: 1px solid var(--vscode-focusBorder) !important; // Override VS Code defaultStyles which are not self-consistent. 31 | outline-offset: -1px; 32 | } 33 | 34 | // ^^ Move to index.html ^^ 35 | 36 | #root { 37 | flex: 1 1 auto; 38 | display: flex; 39 | flex-direction: column; 40 | overflow: hidden; 41 | } 42 | 43 | .flexFill { flex: 1 1 } 44 | 45 | .ellipsis { 46 | white-space: nowrap; 47 | overflow: hidden; 48 | text-overflow: ellipsis; 49 | } 50 | 51 | .svBanner { 52 | padding: 12px 22px; 53 | border-bottom: 1px solid var(--vscode-editorGroup-border); 54 | display: flex; 55 | align-items: center; 56 | line-height: 1.4em; 57 | 58 | & > :not(:first-child) { // Between icon and text. 59 | margin-left: 10px; 60 | } 61 | } 62 | 63 | .svZeroData { 64 | flex: 1 1 auto; 65 | display: flex; 66 | flex-direction: column; 67 | align-items: center; 68 | justify-content: center; 69 | & > * + * { margin-top: 16px; } 70 | 71 | .svButton { 72 | width: 200px; 73 | } 74 | } 75 | 76 | .svButton { 77 | color: var(--vscode-button-foreground); 78 | background: var(--vscode-button-background); 79 | padding: 4px 8px; 80 | line-height: 1.4em; 81 | text-align: center; 82 | cursor: pointer; 83 | } 84 | 85 | .svListPane { 86 | flex: 1 1 auto; 87 | display: flex; 88 | flex-direction: column; 89 | overflow: hidden; 90 | min-height: 20px; // Prevent the splitter from completely hiding the list pane. 91 | 92 | .svTable { 93 | flex: 1 1; 94 | } 95 | } 96 | 97 | .svListHeader { 98 | display: flex; 99 | align-items: center; 100 | padding-right: 8px; 101 | 102 | .codicon { 103 | flex: 0 0 35px; 104 | line-height: 35px; 105 | cursor: pointer; 106 | // &:hover { background-color: var(--vscode-list-hoverBackground); } 107 | } 108 | 109 | .svFilterCombo { 110 | flex: 0 1 250px; 111 | height: 22px; 112 | margin-right: 8px; 113 | display: grid; 114 | & > * { grid-area: 1/1; } 115 | 116 | input { 117 | background-color: var(--vscode-input-background); 118 | color: var(--vscode-input-foreground); 119 | border: none; 120 | padding: 4px 5px; 121 | font-size: 12px; 122 | overflow: hidden; 123 | } 124 | 125 | .codicon { 126 | justify-self: end; 127 | line-height: 22px; 128 | width: 30px; 129 | margin: 1px; // Allow parent outline to show. 130 | background-color: var(--vscode-input-background); // Incase of overlap 131 | } 132 | } 133 | } 134 | 135 | .svListItem { 136 | line-height: 22px; 137 | cursor: pointer; 138 | } 139 | 140 | .svSecondary { 141 | // VS Code has 0.7 here but 0.6 in other places. 142 | // Opacity causing z-order issues with position: sticky, so using manually calced color. 143 | // Need to solve calc relative to theme. 144 | color: rgb(139, 139, 139); 145 | } 146 | 147 | .svLogsPane { 148 | overflow-y: auto; 149 | 150 | .svListItem { 151 | padding: 0 22px; 152 | display: flex; 153 | align-items: center; 154 | white-space: nowrap; 155 | cursor: default; 156 | & > * + * { 157 | margin-left: 6px; 158 | } 159 | 160 | & > :nth-child(2) { 161 | flex: 1 1 auto; 162 | } 163 | 164 | .codicon { 165 | cursor: pointer; 166 | } 167 | } 168 | } 169 | 170 | .svResizer { 171 | flex: 0 0 auto; 172 | background-color: var(--vscode-editorGroup-border); 173 | height: 1px; 174 | position: relative; // For handle. 175 | } 176 | 177 | .svMarkDown { 178 | & > :first-child { 179 | margin-top: 0; 180 | } 181 | 182 | & > :last-child { 183 | margin-bottom: 0; 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/panel/index.tsx: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import { observable } from 'mobx'; 5 | import { observer } from 'mobx-react'; 6 | import * as React from 'react'; 7 | import { Component, Fragment } from 'react'; 8 | import { ReportingDescriptor } from 'sarif'; 9 | import 'vscode-codicons/dist/codicon.css'; 10 | import '../shared/extension'; 11 | import { Details } from './details'; 12 | import { FilterKeywordContext } from './filterKeywordContext'; 13 | import './index.scss'; 14 | import { IndexStore, postLoad, postRefresh } from './indexStore'; 15 | import { ResultTable } from './resultTable'; 16 | import { RowItem } from './tableStore'; 17 | import { Checkrow, Icon, Popover, ResizeHandle, Tab, TabPanel } from './widgets'; 18 | import { decodeFileUri } from '../shared'; 19 | 20 | export { React }; 21 | export * as ReactDOM from 'react-dom'; 22 | export { IndexStore as Store } from './indexStore'; 23 | export { DetailsLayouts } from './details.layouts'; 24 | 25 | @observer export class Index extends Component<{ store: IndexStore }> { 26 | private showFilterPopup = observable.box(false) 27 | private detailsPaneHeight = observable.box(300) 28 | 29 | render() { 30 | const {store} = this.props; 31 | const {banner} = store; 32 | 33 | const bannerElement = banner &&
34 | 35 | {banner} 36 |
postRefresh()}> 37 | Refresh results 38 |
39 |
; 40 | 41 | if (!store.logs.length) { 42 | return <> 43 | {bannerElement} 44 |
45 |
vscode.postMessage({ command: 'open' })}> 46 | Open SARIF log 47 |
48 |
49 | ; 50 | } 51 | 52 | const {logs, keywords} = store; 53 | const {showFilterPopup, detailsPaneHeight} = this; 54 | const activeTableStore = store.selectedTab.get().store; 55 | const allCollapsed = activeTableStore?.groupsFilteredSorted.every(group => !group.expanded) ?? false; 56 | const selectedRow = store.selection.get(); 57 | const selected = selectedRow instanceof RowItem && selectedRow.item; 58 | return 59 | {bannerElement} 60 |
61 | 63 |
64 |
65 | store.keywords = e.target.value} 67 | onKeyDown={e => { if (e.key === 'Escape') { store.keywords = ''; } } }/> 68 | e.stopPropagation()} onClick={() => showFilterPopup.set(!showFilterPopup.get())} /> 69 |
70 | activeTableStore?.groupsFilteredSorted.forEach(group => group.expanded = allCollapsed) } /> 74 | vscode.postMessage({ command: 'closeAllLogs' })} /> 78 | vscode.postMessage({ command: 'open' })} /> 79 | }> 80 | 81 | store.clearFilters()} 82 | renderGroup={(title: string) => { 83 | const {pathname} = new URL(title, 'file:'); 84 | return <> 85 | {pathname.file || 'No Location'} 86 | {pathname.path} 87 | ; 88 | }} /> 89 | 90 | 91 | store.clearFilters()} 92 | renderGroup={(rule: ReportingDescriptor | undefined) => { 93 | return <> 94 | {rule?.name ?? '—'} 95 | {rule?.id ?? '—'} 96 | ; 97 | }} /> 98 | 99 | 100 |
101 | {logs.map((log, i) => { 102 | const {pathname} = new URL(log._uri); 103 | return
104 |
{pathname.file}
105 |
{decodeFileUri(log._uri)}
106 | vscode.postMessage({ command: 'closeLog', uri: log._uri })} /> 108 |
; 109 | })} 110 |
111 |
112 |
113 |
114 |
115 | 116 |
117 |
118 | 119 | {Object.entries(store.filtersRow).map(([name, state]) => 120 |
{name}
121 | {Object.keys(state).map(name => )} 122 |
)} 123 |
124 | {Object.entries(store.filtersColumn).map(([name, state]) => 125 |
{name}
126 | {Object.keys(state).map(name => )} 127 |
)} 128 | 129 | ; 130 | } 131 | 132 | componentDidMount() { 133 | addEventListener('message', this.props.store.onMessage); 134 | postLoad(); 135 | } 136 | 137 | componentWillUnmount() { 138 | removeEventListener('message', this.props.store.onMessage); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/panel/indexStore.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import { action, autorun, computed, intercept, observable, observe, toJS, when } from 'mobx'; 5 | import { Log, PhysicalLocation, ReportingDescriptor, Result } from 'sarif'; 6 | import { augmentLog, CommandExtensionToPanel, filtersColumn, filtersRow, findResult, parseArtifactLocation, Visibility } from '../shared'; 7 | import '../shared/extension'; 8 | import { isActive } from './isActive'; 9 | import { ResultTableStore } from './resultTableStore'; 10 | import { Row, RowItem } from './tableStore'; 11 | 12 | export class IndexStore { 13 | @observable banner = ''; 14 | 15 | private driverlessRules = new Map(); 16 | 17 | constructor(state: Record>>, workspaceUri?: string, defaultSelection?: boolean) { 18 | this.filtersRow = state.filtersRow; 19 | this.filtersColumn = state.filtersColumn; 20 | const setState = async () => { 21 | const {filtersRow, filtersColumn} = this; 22 | const state = { filtersRow: toJS(filtersRow), filtersColumn: toJS(filtersColumn) }; 23 | await vscode.postMessage({ command: 'setState', state: JSON.stringify(state, null, ' ') }); 24 | // PostMessage object key order unstable. Stringify is stable. 25 | }; 26 | // Sadly unable to observe at the root. 27 | observe(this.filtersRow.Level, setState); 28 | observe(this.filtersRow.Baseline, setState); 29 | observe(this.filtersRow.Suppression, setState); 30 | observe(this.filtersColumn.Columns, setState); 31 | 32 | // `change` should be `IArrayWillSplice` but `intercept()` is not being inferred properly. 33 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 34 | intercept(this.logs, (change: any) => { 35 | if (change.type !== 'splice') throw new Error(`Unexpected change type. ${change.type}`); 36 | change.added.forEach((log: Log) => { 37 | augmentLog(log, this.driverlessRules, workspaceUri); 38 | }); 39 | return change; 40 | }); 41 | 42 | observe(this.logs, () => { 43 | if (this.logs.length) return; 44 | this.selection.set(undefined); 45 | }); 46 | 47 | if (defaultSelection) { 48 | const store = this.resultTableStoreByLocation; 49 | when(() => !!store.rows.length, () => { 50 | const item = store.rows.find(row => row instanceof RowItem) as RowItem; 51 | this.selection.set(item); 52 | }); 53 | } 54 | 55 | autorun(() => { 56 | const selectedRow = this.selection.get(); 57 | const result = selectedRow instanceof RowItem && selectedRow.item; 58 | if (!result?._uri) return; // Bail on no result or location-less result. 59 | postSelectArtifact(result, result.locations?.[0]?.physicalLocation); 60 | }); 61 | } 62 | 63 | // Results 64 | @observable.shallow public logs = [] as Log[] 65 | @computed private get runs() { 66 | return this.logs.map(log => log.runs).flat(); 67 | } 68 | @observable resultsFixed = [] as string[] // JSON string of ResultId. TODO: Migrate to set 69 | @computed public get results() { 70 | return this.runs.map(run => run.results ?? []).flat(); 71 | } 72 | selection = observable.box(undefined) 73 | resultTableStoreByLocation = new ResultTableStore('File', result => result._relativeUri, this, this, this.selection) 74 | resultTableStoreByRule = new ResultTableStore('Rule', result => result._rule, this, this, this.selection) 75 | 76 | // Filters 77 | @observable keywords = '' 78 | @observable filtersRow = filtersRow 79 | @observable filtersColumn = filtersColumn 80 | @action public clearFilters() { 81 | this.keywords = ''; 82 | for (const column in this.filtersRow) { 83 | for (const value in this.filtersRow[column]) { 84 | this.filtersRow[column][value] = 'visible'; 85 | } 86 | } 87 | } 88 | 89 | // Tabs 90 | tabs = [ 91 | { toString: () => 'Locations', store: this.resultTableStoreByLocation }, 92 | { toString: () => 'Rules', store: this.resultTableStoreByRule }, 93 | { toString: () => 'Logs', store: undefined }, 94 | ] as { store: ResultTableStore | undefined }[] 95 | selectedTab = observable.box(this.tabs[0], { deep: false }) 96 | 97 | // Messages 98 | @action.bound public async onMessage(event: Pick) { 99 | // During development while running via webpack-dev-server, we need to filter 100 | // out some development specific messages that would not occur in production. 101 | if (!event.data) return; // Ignore mysterious empty message 102 | if (event.data?.source) return; // Ignore 'react-devtools-*' 103 | if (event.data?.type) return; // Ignore 'webpackOk' 104 | 105 | const command = event.data?.command as CommandExtensionToPanel; 106 | 107 | if (command === 'select') { 108 | const {id} = event.data; // id undefined means deselect. 109 | if (!id) { 110 | this.selection.set(undefined); 111 | } else { 112 | const result = findResult(this.logs, id); 113 | if (!result) throw new Error('Unexpected: result undefined'); 114 | this.selectedTab.get().store?.select(result); 115 | } 116 | } 117 | 118 | if (command === 'spliceLogs') { 119 | for (const uri of event.data.removed) { 120 | const i = this.logs.findIndex(log => log._uri === uri); 121 | if (i >= 0) this.logs.splice(i, 1); 122 | } 123 | for (const {text, uri, uriUpgraded, webviewUri} of event.data.added) { 124 | const log: Log = text 125 | ? JSON.parse(text) 126 | : await (await fetch(webviewUri)).json(); 127 | log._uri = uri; 128 | log._uriUpgraded = uriUpgraded; 129 | this.logs.push(log); 130 | } 131 | } 132 | 133 | if (command === 'spliceResultsFixed') { 134 | for (const resultIdString of event.data.removed) { 135 | this.resultsFixed.remove(resultIdString); 136 | } 137 | for (const resultIdString of event.data.added) { 138 | this.resultsFixed.push(resultIdString); 139 | } 140 | } 141 | 142 | if (command === 'setBanner') { 143 | this.banner = event.data?.text ?? ''; 144 | } 145 | } 146 | } 147 | 148 | export async function postLoad() { 149 | await vscode.postMessage({ command: 'load' }); 150 | } 151 | 152 | export async function postSelectArtifact(result: Result, ploc?: PhysicalLocation) { 153 | // If this panel is not active, then any selection change did not originate from (a user's action) here. 154 | // It must have originated from (a user's action in) the editor, which then sent a message here. 155 | // If that is the case, don't send another 'select' message back. This would cause selection unstability. 156 | // The most common example is when the caret is moving, a selection-sync feedback loop will cause a range to 157 | // be selected in editor outside of the user's intent. 158 | if (!isActive()) return; 159 | 160 | if (!ploc) return; 161 | const log = result._log; 162 | const logUri = log._uri; 163 | const [uri, uriBase, uriContent] = parseArtifactLocation(result, ploc?.artifactLocation); 164 | const region = ploc?.region; 165 | await vscode.postMessage({ command: 'select', logUri, uri: uriContent ?? uri, uriBase, region, id: result._id }); 166 | } 167 | 168 | export async function postSelectLog(result: Result) { 169 | await vscode.postMessage({ command: 'selectLog', id: result._id }); 170 | } 171 | 172 | export async function postRefresh() { 173 | await vscode.postMessage({ command: 'refresh' }); 174 | } 175 | 176 | export async function postRemoveResultFixed(result: Result) { 177 | await vscode.postMessage({ command: 'removeResultFixed', id: result._id }); 178 | } 179 | -------------------------------------------------------------------------------- /src/panel/init.js: -------------------------------------------------------------------------------- 1 | const errorHandler = e => { 2 | const errorElement = document.getElementById('error') 3 | errorElement.innerText = `Error: ${e.error.message}`; 4 | errorElement.style.display = 'block'; 5 | } 6 | window.addEventListener('error', errorHandler); 7 | window.addEventListener('unhandledrejection', errorHandler) 8 | 9 | vscode = acquireVsCodeApi(); 10 | (() => { 11 | function getMetaContent(name) { 12 | // We assert the meta name exists as they are hardcoded in the `webview.html`. 13 | return document.querySelector(`meta[name="${name}"]`).content 14 | } 15 | 16 | const store = new Store( 17 | JSON.parse(getMetaContent('storeState')), 18 | getMetaContent('storeWorkspaceUri') || undefined, 19 | ) 20 | store.banner = getMetaContent('storeBanner') 21 | ReactDOM.render( 22 | React.createElement(Index, { store }), 23 | document.getElementById('root'), 24 | ) 25 | })(); 26 | -------------------------------------------------------------------------------- /src/panel/isActive.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | // Using `document.hasFocus()` as the indicator of if this panel is active or not. 5 | export function isActive() { 6 | return document.hasFocus(); 7 | } 8 | -------------------------------------------------------------------------------- /src/panel/resultTable.tsx: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import { observer } from 'mobx-react'; 5 | import * as React from 'react'; 6 | import { PureComponent, ReactNode } from 'react'; 7 | import { Result } from 'sarif'; 8 | import { renderMessageTextWithEmbeddedLinks } from './widgets'; 9 | import { ResultTableStore } from './resultTableStore'; 10 | import { Table } from './table'; 11 | import { Column } from './tableStore'; 12 | 13 | const levelToIcon = { 14 | error: 'error', 15 | warning: 'warning', 16 | note: 'info', 17 | none: 'issues', 18 | undefined: 'question', 19 | }; 20 | 21 | interface ResultTableProps { 22 | store: ResultTableStore; 23 | onClearFilters: () => void; 24 | renderGroup: (group: G) => ReactNode; 25 | } 26 | @observer export class ResultTable extends PureComponent> { 27 | private renderCell = (column: Column, result: Result) => { 28 | const customRenderers = { 29 | 'File': result => {result._uri?.file ?? '—'}, 30 | 'Line': result => {result._region?.startLine ?? '—'}, 31 | 'Message': result => {renderMessageTextWithEmbeddedLinks(result._message, result, vscode.postMessage)}, 32 | 'Rule': result => <> 33 | {result._rule?.name ?? '—'} 34 | {result.ruleId} 35 | , 36 | } as Record ReactNode>; 37 | const defaultRenderer = (result: Result) => { 38 | const capitalize = (str: string) => `${str[0].toUpperCase()}${str.slice(1)}`; 39 | return {capitalize(column.toString(result))}; 40 | }; 41 | const renderer = customRenderers[column.name] ?? defaultRenderer; 42 | return renderer(result); 43 | } 44 | 45 | render() { 46 | const { store, onClearFilters, renderGroup } = this.props; 47 | const { renderCell } = this; 48 | return levelToIcon[result.level ?? 'undefined']} 50 | renderGroup={renderGroup} renderCell={renderCell}> 51 |
52 | No results found with provided filter criteria. 53 |
Clear Filters
54 |
55 |
; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/panel/resultTableStore.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import { Row, RowGroup, RowItem } from './tableStore'; 5 | import { observable } from 'mobx'; 6 | import { filtersRow, filtersColumn } from '../shared'; 7 | import { ResultTableStore } from './resultTableStore'; 8 | import { log } from '../test/mockResultTableStore'; 9 | import assert from 'assert'; 10 | import { Result } from 'sarif'; 11 | 12 | describe('ResultTableStore', () => { 13 | const resultsSource = { 14 | results: log.runs![0].results!, 15 | resultsFixed: [] 16 | }; 17 | const selection = observable.box(undefined); 18 | const filtersSource = { 19 | keywords: '', 20 | filtersRow: filtersRow, 21 | filtersColumn: filtersColumn 22 | }; 23 | 24 | it('creates different visible columns based on Group Name provided', () => { 25 | const resultTableStore = new ResultTableStore('File', result => result._relativeUri, resultsSource, filtersSource, selection); 26 | assert.deepStrictEqual(resultTableStore.columns.map((col) => col.name), ['Line', 'File', 'Message', 'Baseline', 'Suppression', 'Rule']); 27 | 28 | const resultTableStore1 = new ResultTableStore('File', result => result._relativeUri, resultsSource, filtersSource, selection); 29 | assert.deepStrictEqual(resultTableStore1.visibleColumns.map((col) => col.name), ['Line', 'Message']); 30 | 31 | const resultTableStore2 = new ResultTableStore('Line', result => result._region?.startLine ?? 0, resultsSource, filtersSource, selection); 32 | assert.deepStrictEqual(resultTableStore2.visibleColumns.map((col) => col.name), ['File', 'Message']); 33 | 34 | const resultTableStore3 = new ResultTableStore('Message', result => result._message, resultsSource, filtersSource, selection); 35 | assert.deepStrictEqual(resultTableStore3.visibleColumns.map((col) => col.name), ['Line', 'File']); 36 | }); 37 | 38 | it.skip('groups the rows and rowItems based the grouping logic applied on resultsSource', () => { 39 | const groupBy = (result: Result) => result.locations 40 | ? result.locations[0].physicalLocation?.artifactLocation?.uri === '/folder/file_1.txt' ? 'file_1' : 'non file_1' 41 | : undefined; 42 | const resultTableStore = new ResultTableStore('File', groupBy, resultsSource, filtersSource, selection); 43 | assert.strictEqual(resultTableStore.rows.length, 2); // Failing due to change in suppression filter defaults. 44 | assert.strictEqual((resultTableStore.rows[0] as RowGroup).title, 'non file_1'); 45 | assert.strictEqual((resultTableStore.rows[1] as RowItem>>).item.message.text, 'Message 6'); 46 | assert.strictEqual(resultTableStore.rowItems.length, 6); 47 | assert.strictEqual((resultTableStore.rowItems[0].group as RowGroup).title, 'file_1'); 48 | const nonFile1GroupRowItems = resultTableStore.rowItems.slice(1, resultTableStore.rowItems.length); 49 | assert.deepStrictEqual(nonFile1GroupRowItems.map((rowItem) => (rowItem.group as RowGroup).title), ['non file_1', 'non file_1', 'non file_1', 'non file_1', 'non file_1']); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/panel/resultTableStore.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import { computed, IObservableValue } from 'mobx'; 5 | import { Result } from 'sarif'; 6 | import { Visibility } from '../shared'; 7 | import { IndexStore } from './indexStore'; 8 | import { Column, Row, TableStore } from './tableStore'; 9 | 10 | export class ResultTableStore extends TableStore { 11 | constructor( 12 | readonly groupName: string, 13 | readonly groupBy: (item: Result) => G | undefined, 14 | private readonly resultsSource: Pick, 15 | readonly filtersSource: { 16 | keywords: string; 17 | filtersRow: Record>; 18 | filtersColumn: Record>; 19 | }, 20 | readonly selection: IObservableValue) { 21 | super( 22 | groupBy, 23 | resultsSource, 24 | selection, 25 | ); 26 | this.sortColumn = this.columnsPermanent[0].name; 27 | } 28 | 29 | // Columns 30 | private columnsPermanent = [ 31 | new Column('Line', 50, result => result._region?.startLine?.toString() ?? '—', result => result._region?.startLine ?? 0), 32 | new Column('File', 250, result => result._relativeUri ?? ''), 33 | new Column('Message', 300, result => result._message ?? ''), 34 | ] 35 | private columnsOptional = [ 36 | new Column('Baseline', 100, result => result.baselineState ?? ''), 37 | new Column('Suppression', 100, result => result._suppression ?? ''), 38 | new Column('Rule', 220, result => `${result._rule?.name ?? '—'} ${result.ruleId ?? '—'}`), 39 | ] 40 | get columns() { 41 | return [...this.columnsPermanent, ...this.columnsOptional]; 42 | } 43 | @computed get visibleColumns() { 44 | const {filtersColumn} = this.filtersSource; 45 | const optionalColumnNames = Object.entries(filtersColumn.Columns) 46 | .filter(([_, state]) => state) 47 | .map(([name, ]) => name); 48 | return [ 49 | ...this.columnsPermanent.filter(col => col.name !== this.groupName), 50 | ...this.columnsOptional.filter(col => optionalColumnNames.includes(col.name)) 51 | ]; 52 | } 53 | 54 | protected get filter() { 55 | const {keywords, filtersRow} = this.filtersSource; 56 | const {columns} = this; 57 | const mapToList = (record: Record) => Object.entries(record) 58 | .filter(([, value]) => value) 59 | .map(([label,]) => label.toLowerCase()); 60 | 61 | const levels = mapToList(filtersRow.Level); 62 | const baselines = mapToList(filtersRow.Baseline); 63 | const suppressions = mapToList(filtersRow.Suppression); 64 | const filterKeywords = keywords.toLowerCase().split(/\s+/).filter(part => part); 65 | 66 | return (result: Result) => { 67 | if (!levels.includes(result.level ?? '')) return false; 68 | if (!baselines.includes(result.baselineState ?? '')) return false; 69 | if (!suppressions.includes(result._suppression ?? '')) return false; 70 | return columns.some(col => { 71 | const isMatch = (field: string, keywords: string[]) => !keywords.length || keywords.some(keyword => field.includes(keyword)); 72 | const {toString} = col; 73 | const field = toString(result).toLowerCase(); 74 | return isMatch(field, filterKeywords); 75 | }); 76 | }; 77 | } 78 | 79 | public isLineThrough(result: Result): boolean { 80 | return this.resultsSource.resultsFixed.includes(JSON.stringify(result._id)); 81 | } 82 | 83 | public menuContext(result: Result): Record | undefined { 84 | // If no alertNumber, then don't show the context menu (which contains the Dismiss Alert commands). 85 | if (!result.properties?.['github/alertNumber']) return undefined; 86 | 87 | return { webviewSection: 'isGithubAlert', resultId: JSON.stringify(result._id) }; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/panel/table.scss: -------------------------------------------------------------------------------- 1 | .svTable { 2 | display: flex; 3 | flex-direction: column; 4 | overflow: hidden; 5 | cursor: pointer; 6 | user-select: none; 7 | 8 | .svTableCell { 9 | padding: 0 0 0 8px; 10 | display: flex; 11 | align-items: center; 12 | white-space: nowrap; 13 | 14 | & > * { // Cell-part. 15 | overflow: hidden; 16 | text-overflow: ellipsis; 17 | } 18 | & > * + * { margin-left: 6px; } // Cell-part. 19 | } 20 | 21 | // We can to avoid a line-through on the icon which is a
22 | // However, targeting is an abstraction break. 23 | .svTableRowItem.svLineThrough > .svTableCell > span { 24 | text-decoration: line-through; 25 | opacity: 0.5; 26 | } 27 | 28 | .svTableHeader { 29 | display: grid; 30 | grid-auto-rows: 33px; 31 | color: rgb(139, 139, 139); 32 | & > * { // Cell. 33 | position: relative; // For resize handle. 34 | &:hover { background-color: var(--vscode-list-hoverBackground); } 35 | } 36 | } 37 | 38 | .svTableBody { 39 | flex: 1 1; 40 | display: grid; 41 | grid-auto-rows: 22px; 42 | overflow: auto; 43 | scroll-padding: 48px 0; 44 | 45 | .svTableRow { 46 | grid-column: 1 / -1; 47 | } 48 | 49 | .svTableRowGroup { 50 | display: flex; 51 | } 52 | 53 | .svTableRowItem { 54 | display: grid; 55 | grid-template-columns: auto 1fr; 56 | } 57 | 58 | // Closely related to svDetailsCodeflow. 59 | & { 60 | .svTableRow:hover { background-color: var(--vscode-list-hoverBackground); } 61 | } 62 | &.svSelected { 63 | .svTableRow.svItemSelected { background: var(--vscode-list-inactiveSelectionBackground); } 64 | } 65 | &.svSelected:focus { 66 | outline: none !important; 67 | .svTableRow.svItemSelected { background: var(--vscode-list-activeSelectionBackground); } 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/panel/table.tsx: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import { action, computed } from 'mobx'; 5 | import { observer } from 'mobx-react'; 6 | import * as React from 'react'; 7 | import { KeyboardEvent, memo, PureComponent, ReactNode } from 'react'; 8 | import { Badge, css, Hi, Icon, ResizeHandle } from './widgets'; 9 | import './table.scss'; 10 | import { Column, RowGroup, RowItem, TableStore } from './tableStore'; 11 | 12 | interface TableProps { 13 | columns: Column[]; 14 | renderIconName?: (item: T) => string; 15 | renderGroup: (group: G) => ReactNode; 16 | renderCell: (column: Column, itemData: T) => ReactNode; 17 | store: TableStore; 18 | } 19 | @observer export class Table extends PureComponent> { 20 | @computed get gridTemplateColumns() { 21 | const {columns} = this.props; 22 | return [ 23 | '34px', // Left margin. Aligns with tabs left margin (22px) + group chevron (12px). 24 | // Variable number of columns set to user-desired width. 25 | // First column has an extra 22px allowance for the `level` icon. 26 | ...columns.map((col, i) => `${(i === 0 ? 22 : 0) + col.width.get()}px`), 27 | '1fr', // Fill remaining space so the the selection/hover highlight doesn't look funny. 28 | ].join(' '); 29 | } 30 | 31 | private TableItem = memo<{ isLineThrough: boolean, isSelected: boolean, item: RowItem, gridTemplateColumns: string, menuContext: Record | undefined }>(props => { 32 | const { columns, store, renderIconName, renderCell } = this.props; 33 | const { isLineThrough, isSelected, item, gridTemplateColumns, menuContext } = props; 34 | return
{ // TODO: ForwardRef for Group 37 | if (!isSelected || !ele) return; 38 | setTimeout(() => ele.scrollIntoView({ behavior: 'smooth', block: 'nearest' })); // requestAnimationFrame not working. 39 | }} 40 | onClick={e => { 41 | e.stopPropagation(); 42 | store.selection.set(item); 43 | }}> 44 |
45 | {columns.map((col, i) => 47 | {i === 0 && renderIconName && } 48 | {renderCell(col, item.item)} 49 | )} 50 |
; 51 | }) 52 | 53 | render() { 54 | const {TableItem} = this; 55 | const {columns, store, renderGroup, children} = this.props; 56 | const {rows, selection} = store; 57 | return !rows.length 58 | ? children // Zero data. 59 | :
60 |
61 |
62 | {columns.map(col =>
store.toggleSort(col.name))}> 64 | {col.name}{/* No spacing */} 65 | {store.sortColumn === col.name && } 66 | 67 |
)} 68 |
69 |
70 | {rows.map(row => { 71 | const isSelected = selection.get() === row; 72 | if (row instanceof RowGroup) { 73 | return { 75 | e.stopPropagation(); 76 | selection.set(row); 77 | row.expanded = !row.expanded; 78 | }}> 79 |
80 | 81 | {renderGroup(row.title)} 82 | 83 |
; 84 | } 85 | if (row instanceof RowItem) { 86 | // Must evaluate isLineThrough outside of so the function component knows to update. 87 | return ; 93 | } 94 | return undefined; // Closed system: No other types expected. 95 | })} 96 |
97 |
; 98 | } 99 | 100 | @action.bound private onKeyDown(e: KeyboardEvent) { 101 | const {store} = this.props; 102 | const {rows, selection} = store; 103 | const index = rows.indexOf(selection.get()); // Rows 104 | const handlers = { 105 | ArrowUp: () => selection.set(rows[index - 1] ?? rows[index] ?? rows[0]), 106 | ArrowDown: () => selection.set(rows[index + 1] ?? rows[index]), 107 | Escape: () => selection.set(undefined) 108 | } as Record void>; 109 | const handler = handlers[e.key]; 110 | if (handler) { 111 | e.stopPropagation(); 112 | e.preventDefault(); // Prevent scrolling. 113 | handler(); 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/panel/tableStore.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import { TableStore, RowGroup } from './tableStore'; 5 | import { observable } from 'mobx'; 6 | import assert from 'assert'; 7 | 8 | describe('TableStore', () => { 9 | const groupBy = (item: number) => item % 2 === 0 ? 'even' : 'odd'; 10 | const selection = observable.box(); 11 | 12 | it.skip('should collapse and expand row groups', () => { 13 | const itemSource = { results: [1,2,3,4,5] }; 14 | const tableStore = new TableStore(groupBy, itemSource, selection); 15 | 16 | // Verify rows based on default value for expanded property 17 | // TODO: compare 2 lists and their types? 18 | assert.strictEqual(tableStore.rows.length, 7); 19 | assert.strictEqual((tableStore.rows[0] as RowGroup).title, 'odd'); 20 | 21 | // Collapse "odd" group 22 | (tableStore.rows[0] as RowGroup).expanded = false; 23 | assert.strictEqual(tableStore.rows.length, 4); 24 | 25 | // Expand "odd" group 26 | (tableStore.rows[0] as RowGroup).expanded = true; 27 | assert.strictEqual(tableStore.rows.length, 7); 28 | 29 | // Collapse "even" group 30 | assert.strictEqual((tableStore.rows[4] as RowGroup).title, 'even'); 31 | (tableStore.rows[4] as RowGroup).expanded = false; 32 | assert.strictEqual(tableStore.rows.length, 5); 33 | 34 | // Expand "even" group 35 | (tableStore.rows[4] as RowGroup).expanded = true; 36 | assert.strictEqual(tableStore.rows.length, 7); 37 | 38 | // Collapse both "odd" and "even" group 39 | (tableStore.rows[0] as RowGroup).expanded = false; 40 | assert.strictEqual(tableStore.rows.length, 4); 41 | (tableStore.rows[1] as RowGroup).expanded = false; 42 | assert.strictEqual(tableStore.rows.length, 2); 43 | }); 44 | 45 | it ('should verify the default sorting for the row groups', () => { 46 | // tableStore - when # of odd elements more than # of even elements 47 | const itemSource = { results: [1,2,3,4,5] }; 48 | const tableStore = new TableStore(groupBy, itemSource, selection); 49 | 50 | // "odd" row group would be sorted high in the list 51 | assert.strictEqual((tableStore.rows[0] as RowGroup).title, 'odd'); 52 | 53 | // tableStore - when # of even elements are more than # of odd elements 54 | const itemSource2 = { results: [1,2,3,4,5,6,8,10] }; 55 | const tableStore2 = new TableStore(groupBy, itemSource2, selection); 56 | 57 | // "even" row group would be sorted high in the list 58 | assert.strictEqual((tableStore2.rows[0] as RowGroup).title, 'even'); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/panel/tableStore.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import { action, computed, IObservableValue, observable } from 'mobx'; 5 | import '../shared/extension'; 6 | 7 | export class Column { 8 | width: IObservableValue 9 | constructor( 10 | readonly name: string, 11 | width: number, 12 | readonly toString: (item: T) => string, 13 | readonly toNumber?: (item: T) => number /* For sorting */) { 14 | this.width = observable.box(width); 15 | } 16 | } 17 | 18 | export abstract class Row { 19 | private static instances = 0 20 | public readonly key = Row.instances++ 21 | } 22 | 23 | export class RowGroup extends Row { 24 | private expandedState: IObservableValue; 25 | get expanded() { 26 | return this.expandedState.get(); 27 | } 28 | set expanded(value: boolean) { 29 | this.expandedState.set(value); 30 | } 31 | public items = [] as RowItem[] 32 | public itemsFiltered = [] as RowItem[] 33 | constructor(readonly title: G, expansionStates: Map>) { 34 | super(); 35 | if (!expansionStates.has(title)) { 36 | expansionStates.set(title, observable.box(false)); 37 | } 38 | this.expandedState = expansionStates.get(this.title)!; 39 | } 40 | } 41 | 42 | export class RowItem extends Row { 43 | public group?: { expanded: boolean } 44 | constructor(readonly item: T) { 45 | super(); 46 | } 47 | } 48 | 49 | enum SortDir { 50 | Asc = 'arrow-down', 51 | Dsc = 'arrow-up', 52 | } 53 | 54 | export class TableStore { 55 | private expansionStates = new Map>(); 56 | constructor( 57 | readonly groupBy: (item: T) => G | undefined, 58 | readonly itemsSource: { results: ReadonlyArray }, // Abstraction break. 59 | readonly selection: IObservableValue) { 60 | } 61 | 62 | @computed({ keepAlive: true }) public get rowItems() { 63 | return this.itemsSource.results.map(result => new RowItem(result)); 64 | } 65 | @computed({ keepAlive: true }) private get groups() { 66 | const map = new Map>(); 67 | this.rowItems.forEach(item => { 68 | const key = this.groupBy(item.item); 69 | if (!map.has(key)) map.set(key, new RowGroup(key, this.expansionStates)); 70 | const group = map.get(key)!; 71 | group.items.push(item); 72 | item.group = group; 73 | }); 74 | return [...map.values()].sortBy(g => g.items.length, true); // High to low. 75 | } 76 | 77 | // Unable to express "columns of any varying types" otherwise. 78 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 79 | get columns(): Column[] { return []; } 80 | protected get filter() { return (_item: T) => true; } 81 | @observable public sortColumn = undefined as string | undefined 82 | @observable public sortDir = SortDir.Asc 83 | @action toggleSort(newCol: string) { 84 | if (this.sortColumn === newCol) { 85 | this.sortDir = this.sortDir === SortDir.Asc ? SortDir.Dsc : SortDir.Asc; 86 | } else { 87 | this.sortColumn = newCol; 88 | this.sortDir = SortDir.Asc; 89 | } 90 | } 91 | sort(items: RowItem[]) { 92 | const {columns, sortColumn, sortDir} = this; 93 | const column = columns.find(col => col.name === sortColumn); 94 | if (!column) return; 95 | const {toNumber, toString} = column; 96 | const toSortable = toNumber ?? toString; 97 | items.sortBy(item => toSortable(item.item), sortDir === SortDir.Dsc); 98 | } 99 | 100 | @computed public get groupsFilteredSorted() { 101 | const {groups, filter} = this; 102 | for (const group of groups) { 103 | group.itemsFiltered = group.items.filter(item => filter?.(item.item) ?? true); 104 | this.sort(group.itemsFiltered); 105 | } 106 | return this.groups.filter(group => group.itemsFiltered.length); 107 | } 108 | @computed public get rows() { 109 | const rows = [] as Row[]; 110 | for (const group of this.groupsFilteredSorted) { 111 | rows.push(group); 112 | if (group.expanded) rows.push(...group.itemsFiltered); 113 | } 114 | return rows; 115 | } 116 | 117 | select(item: T) { 118 | const row = this.rowItems.find(row => row.item === item); 119 | this.selection.set(row); 120 | if (row?.group) row.group.expanded = true; 121 | } 122 | 123 | isLineThrough(_item: T): boolean { 124 | return false; 125 | } 126 | 127 | menuContext(_item: T): Record | undefined { 128 | return undefined; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/panel/widgets.scss: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | .codicon-error { color: var(--vscode-problemsErrorIcon-foreground); } 5 | .codicon-warning { color: var(--vscode-problemsWarningIcon-foreground); } 6 | .codicon-info { color: var(--vscode-problemsInfoIcon-foreground); } 7 | .codicon-issues { color: var(--vscode-problemsInfoIcon-foreground); } 8 | .codicon-question { color: var(--vscode-problemsInfoIcon-foreground); } 9 | 10 | .svBadge { 11 | padding: 3px 5px; 12 | border-radius: 11px; 13 | font-size: 11px; 14 | min-width: 18px; 15 | min-height: 18px; 16 | line-height: 11px; 17 | font-weight: 400; 18 | text-align: center; 19 | display: inline-block; 20 | box-sizing: border-box; 21 | background-color: var(--vscode-badge-background); 22 | color: var(--vscode-badge-foreground); 23 | } 24 | 25 | .svCheckrow { 26 | cursor: pointer; 27 | padding: 3px 0; 28 | display: flex; 29 | align-items: center; 30 | 31 | .svCheckbox { 32 | box-sizing: border-box; 33 | height: 18px; 34 | width: 18px; 35 | border: 1px solid transparent; 36 | border-radius: 3px; 37 | margin-left: 0; 38 | margin-right: 9px; // Matches VS Code settings page. 39 | background-color: var(--vscode-input-background); 40 | 41 | .codicon { 42 | width: unset !important; 43 | line-height: unset !important; 44 | opacity: 0; 45 | } 46 | 47 | &.svChecked { 48 | .codicon { opacity: 1; } 49 | } 50 | } 51 | 52 | &.svWithDescription { 53 | align-items: flex-start; 54 | 55 | .svWithDescription { 56 | div + div { 57 | margin-top: 4px; 58 | } 59 | div:nth-child(2) { 60 | color: rgb(139, 139, 139); // svSecondary 61 | } 62 | } 63 | } 64 | } 65 | 66 | .svList { 67 | &.svListZero { 68 | display: grid; 69 | align-items: center; 70 | justify-items: center; 71 | } 72 | } 73 | 74 | .svPopover { 75 | position: absolute; 76 | z-index: 1; 77 | background: var(--vscode-menu-background); 78 | box-shadow: var(--vscode-widget-shadow) 0px 0px 8px; 79 | padding: 10px 28px 14px 16px; 80 | user-select: none; 81 | display: flex; 82 | flex-direction: column; 83 | color: var(--vscode-foreground); // VS Code has alpha=0.9, but couldn't find the function. 84 | 85 | & > div.svPopoverTitle { 86 | &:not(:first-child) { 87 | margin-top: 12px; 88 | } 89 | color: var(--vscode-settings-headerForeground); 90 | font-weight: 600; 91 | margin-bottom: 2px; 92 | } 93 | 94 | & > .svPopoverDivider { 95 | height: 1px; 96 | margin: 12px -28px 0 -16px; 97 | background: var(--vscode-editorGroup-border); 98 | } 99 | } 100 | 101 | .svTabs { 102 | display: flex; 103 | padding: 0 12px; 104 | user-select: none; 105 | 106 | & > .svListItem { 107 | cursor: pointer; 108 | padding: 4px 10px 3px; 109 | 110 | & > div { 111 | display: flex; 112 | align-items: center; 113 | border-bottom: 1px solid transparent; 114 | text-transform: uppercase; 115 | font-size: 11px; 116 | line-height: 27px; 117 | color: var(--vscode-panelTitle-activeForeground); 118 | color: rgb(139, 139, 139); // svSecondary 119 | 120 | .svBadge { 121 | margin-left: 6px; 122 | } 123 | } 124 | 125 | &.svItemSelected > div { 126 | color: var(--vscode-panelTitle-activeForeground); 127 | border-bottom-color: var(--vscode-panelTitle-activeForeground); 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/shared/extension.spec.ts: -------------------------------------------------------------------------------- 1 | 2 | // Copyright (c) Microsoft Corporation. All rights reserved. 3 | // Licensed under the MIT License. 4 | 5 | import assert from 'assert'; 6 | 7 | describe('Extension', () => { 8 | describe('Array.prototype.last', () => { 9 | it('finds the last element when more than 1 elements are present', () => { 10 | assert.strictEqual(['a', 'b', 'c'].last, 'c'); 11 | }); 12 | it('returns the only element in the array when there is a single element present', () => { 13 | assert.strictEqual(['a'].last, 'a'); 14 | }); 15 | it('does not fail if array is empty', () => { 16 | assert.doesNotThrow(() => [].last); 17 | }); 18 | }); 19 | describe('Array.prototype.removeFirst', () => { 20 | const logs = [ 21 | { '_uri': 'uri1' }, 22 | { '_uri': 'uri2' }, 23 | { '_uri': 'uri2' } 24 | ]; 25 | it('removes the first occurrence of matching', () => { 26 | assert.deepStrictEqual(logs.removeFirst(log => log._uri === 'uri2'), {'_uri': 'uri2'}); 27 | assert.deepStrictEqual(logs.map(log => log),[ 28 | {'_uri': 'uri1'}, 29 | {'_uri': 'uri2'} 30 | ]); 31 | }); 32 | it('returns false no element match', () => { 33 | assert.strictEqual(logs.removeFirst(log => log._uri === 'uri5'), false); 34 | assert.deepStrictEqual(logs.map(log => log), [ 35 | {'_uri': 'uri1'}, 36 | {'_uri': 'uri2'} 37 | ]); 38 | }); 39 | it('returns false when tries to remove from empty array', () => { 40 | assert.strictEqual([].removeFirst(log => log === 'uri5'), false); 41 | }); 42 | }); 43 | describe('String.prototype.sortBy', () => { 44 | it('sorts strings', () => { 45 | const sortedArrayAsc = ['c','b', 'a', 'd'].sortBy(item => String(item)); 46 | assert.deepStrictEqual(sortedArrayAsc.map(i => i), ['a', 'b', 'c', 'd']); 47 | const sortedArrayDesc = ['c','b', 'a', 'd'].sortBy(item => String(item), true); 48 | assert.deepStrictEqual(sortedArrayDesc.map(i => i), ['d', 'c', 'b', 'a']); 49 | }); 50 | it('sorts numbers', () => { 51 | const sortedArray = [1,3,2,4].sortBy(item => Number(item)); 52 | assert.deepStrictEqual(sortedArray.map(i => i), [1,2,3,4]); 53 | const sortedArrayDesc = [1,3,2,4].sortBy(item => Number(item), true); 54 | assert.deepStrictEqual(sortedArrayDesc.map(i => i), [4,3,2,1]); 55 | }); 56 | it('sorts in-place', () => { 57 | const originalArrayStrings = ['c','b', 'a', 'd']; 58 | originalArrayStrings.sortBy(item => String(item)); 59 | assert.deepStrictEqual(originalArrayStrings.map(i => i), ['a', 'b', 'c', 'd']); 60 | originalArrayStrings.sortBy(item => String(item), true); 61 | assert.deepStrictEqual(originalArrayStrings.map(i => i), ['d', 'c', 'b', 'a']); 62 | const originalArrayNumbers = [1,4,2,3]; 63 | originalArrayNumbers.sortBy(item => Number(item)); 64 | assert.deepStrictEqual(originalArrayNumbers.map(i => i), [1,2,3,4]); 65 | originalArrayNumbers.sortBy(item => Number(item), true); 66 | assert.deepStrictEqual(originalArrayNumbers.map(i => i), [4,3,2,1]); 67 | }); 68 | }); 69 | describe('String.prototype.file', () => { 70 | it('returns the file name from a path', () => { 71 | assert.strictEqual('/C:/Users/user.cs'.file, 'user.cs'); 72 | }); 73 | it('does not fail when there is no file type', () => { 74 | assert.doesNotThrow(() => '/C:/Users/user'.file); 75 | }); 76 | it('does not fail when there is no hierarchical directory path as part of input', () => { 77 | assert.doesNotThrow(() => 'user.cs'.file); 78 | }); 79 | it('does not fail when input is empty', () => { 80 | assert.doesNotThrow(() => ''.file); 81 | }); 82 | }); 83 | describe('String.prototype.path', () => { 84 | it('returns the hierarchical directory from the file path', () => { 85 | assert.strictEqual('/C:/Users/user.cs'.path, 'C:/Users'); 86 | }); 87 | it('does not fail when when no hierarchical directory is in the input', () => { 88 | assert.doesNotThrow(() => 'user.cs'.path); 89 | }); 90 | it('does not fail when input is empty', () => { 91 | assert.doesNotThrow(() => ''.path); 92 | }); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /src/shared/extension.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | /* eslint-disable no-prototype-builtins */ // Only using prototype on `Array` `Object` which are safe. 5 | /* eslint-disable @typescript-eslint/no-explicit-any */ // Unable to express certain generic extensions. 6 | 7 | export {}; 8 | 9 | // Causing colorization issues if placed above Array.prototype... 10 | // Ideally: ((_) => number) | ((_) => string) 11 | type Selector = (_: T) => number | string 12 | 13 | declare global { 14 | interface Array { 15 | last: T; 16 | replace(items: T[]): void; // From Mobx, but not showing up. 17 | remove(item: T): boolean; // From Mobx, but not showing up. 18 | removeFirst(predicate: (item: T) => boolean): T | false; 19 | sortBy(this: T[], selector: Selector, descending?: boolean): Array; // Not a copy 20 | } 21 | interface String { 22 | file: string; 23 | path: string; 24 | } 25 | } 26 | 27 | !Array.prototype.hasOwnProperty('last') && 28 | Object.defineProperty(Array.prototype, 'last', { 29 | get: function() { 30 | return this[this.length - 1]; 31 | } 32 | }); 33 | 34 | !Array.prototype.hasOwnProperty('removeFirst') && 35 | Object.defineProperty(Array.prototype, 'removeFirst', { 36 | value: function(predicate: (item: any) => boolean) { // Unable to express (item: T) so using (item: any). 37 | const i = this.findIndex(predicate); 38 | return i >= 0 && this.splice(i, 1).pop(); 39 | } 40 | }); 41 | 42 | Array.prototype.sortBy = function(selector: Selector, descending = false) { 43 | this.sort((a, b) => { 44 | const aa = selector(a); 45 | const bb = selector(b); 46 | const invert = descending ? -1 : 1; 47 | if (typeof aa === 'string' && typeof bb === 'string') return invert * aa.localeCompare(bb); 48 | if (typeof aa === 'number' && typeof bb === 'number') return invert * (aa - bb); 49 | return 0; 50 | }); 51 | return this; 52 | }; 53 | 54 | !String.prototype.hasOwnProperty('file') && 55 | Object.defineProperty(String.prototype, 'file', { 56 | get: function() { 57 | return this.substring(this.lastIndexOf('/') + 1, this.length); 58 | } 59 | }); 60 | 61 | !String.prototype.hasOwnProperty('path') && 62 | Object.defineProperty(String.prototype, 'path', { 63 | get: function() { 64 | return this.substring(0, this.lastIndexOf('/')).replace(/^\//g, ''); 65 | } 66 | }); 67 | -------------------------------------------------------------------------------- /src/shared/index.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import assert from 'assert'; 5 | import { Log, ReportingDescriptor, Result, Run } from 'sarif'; 6 | import { augmentLog, decodeFileUri, effectiveLevel } from '.'; 7 | import './extension'; 8 | 9 | describe('augmentLog', () => { 10 | const log = { 11 | version: '2.1.0', 12 | runs: [{ 13 | tool: { 14 | driver: { name: 'Driver' } 15 | }, 16 | results: [{ 17 | message: { 18 | text: 'Message 1' 19 | }, 20 | locations: [{ 21 | physicalLocation: { 22 | artifactLocation: { 23 | uri: '/folder/file.txt', 24 | } 25 | } 26 | }] 27 | }] 28 | }] 29 | } as Log; 30 | const result = log.runs![0].results![0]; 31 | // Helper to visualize: console.log(JSON.stringify(result, null, ' ')) 32 | 33 | it('add augmented fields', () => { 34 | augmentLog(log); 35 | assert.strictEqual(result._uri, '/folder/file.txt'); 36 | assert.strictEqual(result._message, 'Message 1'); 37 | }); 38 | 39 | it('resolves artifactLocation.index', () => { 40 | log._augmented = false; 41 | result.locations![0].physicalLocation!.artifactLocation!.index = 0; 42 | log.runs[0].artifacts = [{ 43 | location: { 44 | uri: '/folder/file.txt' 45 | }, 46 | contents: { 47 | text: 'abcdef' 48 | } 49 | }]; 50 | 51 | augmentLog(log); 52 | assert.strictEqual(result._uriContents, 'sarif:undefined/0/0/file.txt'); 53 | }); 54 | 55 | it('is able to reuse driverless rule instances across runs', () => { 56 | const placeholderTool = { 57 | driver: { name: 'Driver' } 58 | }; 59 | const placeholderMessage = { 60 | text: 'Message 1' 61 | }; 62 | const run0result = { 63 | message: placeholderMessage, 64 | ruleId: 'TEST001', 65 | } as Result; 66 | const run1result = { 67 | message: placeholderMessage, 68 | ruleId: 'TEST001', 69 | } as Result; 70 | const log = { 71 | runs: [ 72 | { 73 | tool: placeholderTool, 74 | results: [run0result] 75 | }, 76 | { 77 | tool: placeholderTool, 78 | results: [run1result] 79 | } 80 | ] 81 | } as Log; 82 | 83 | augmentLog(log, new Map()); 84 | assert.strictEqual(run0result._rule, run1result._rule); 85 | }); 86 | }); 87 | 88 | describe('effectiveLevel', () => { 89 | it(`treats non-'fail' results appropriately`, () => { 90 | let result = { 91 | kind: 'informational' 92 | } as Result; 93 | 94 | assert.strictEqual(effectiveLevel(result), 'note'); 95 | 96 | result = { 97 | kind: 'notApplicable' 98 | } as Result; 99 | 100 | assert.strictEqual(effectiveLevel(result), 'note'); 101 | 102 | result = { 103 | kind: 'pass' 104 | } as Result; 105 | 106 | assert.strictEqual(effectiveLevel(result), 'note'); 107 | 108 | result = { 109 | kind: 'open' 110 | } as Result; 111 | 112 | assert.strictEqual(effectiveLevel(result), 'warning'); 113 | 114 | result = { 115 | kind: 'review' 116 | } as Result; 117 | 118 | assert.strictEqual(effectiveLevel(result), 'warning'); 119 | }); 120 | 121 | it (`treats 'fail' according to 'level'`, () => { 122 | const result = { 123 | kind: 'fail', 124 | level: 'error' 125 | } as Result; 126 | 127 | assert.strictEqual(effectiveLevel(result), 'error'); 128 | }); 129 | 130 | it (`takes 'level' from 'rule' if necessary`, () => { 131 | const run = { 132 | tool: { 133 | driver: { 134 | rules: [ 135 | { 136 | defaultConfiguration: { 137 | level: 'error' 138 | } 139 | } 140 | ] 141 | } 142 | }, 143 | results: [ 144 | { 145 | kind: 'fail' 146 | // 'level' not specified. 147 | }, 148 | { 149 | // Neither 'kind' nor 'level' specified. 150 | } 151 | ] 152 | } as Run; 153 | 154 | // Hook up each result to its rule. 155 | const rule = run.tool.driver.rules![0]; 156 | run.results![0]._rule = rule; 157 | run.results![1]._rule = rule; 158 | 159 | assert.strictEqual(effectiveLevel(run.results![0]), 'error'); 160 | assert.strictEqual(effectiveLevel(run.results![1]), 'error'); 161 | }); 162 | }); 163 | 164 | describe('decodeFileUri', () => { 165 | // Skipping while we fix this test for non-Win32 users. 166 | it.skip(`decodes the 'file' uri schemes`, () => { 167 | const originalUriString = 'file:///c%3A/Users/muraina/sarif-tutorials/samples/3-Beyond-basics/Results_2.sarif'; 168 | assert.strictEqual(decodeFileUri(originalUriString), 'c:\\Users\\muraina\\sarif-tutorials\\samples\\3-Beyond-basics\\Results_2.sarif'); 169 | }); 170 | it(`gets authority for https uri schemes`, () => { 171 | assert.strictEqual(decodeFileUri('https://programmers.stackexchange.com/x/y?a=b#123'), 'programmers.stackexchange.com'); 172 | }); 173 | 174 | it(`does not affect other uri schemes`, () => { 175 | assert.strictEqual(decodeFileUri('sarif://programmers.stackexchange.com/x/y?a=b#123'), 'sarif://programmers.stackexchange.com/x/y?a=b#123'); 176 | }); 177 | }); 178 | 179 | /* 180 | Global State Test Notes 181 | - Basic 182 | - Clear State 183 | - Change filter 184 | - Choice: 185 | - Close tab, reopen tab 186 | - Close window, reopen tab 187 | - Verify 188 | - Checks maintained 189 | - Order maintained 190 | - Versioning 191 | - Make sure version isn't lost on roundtrip. 192 | */ 193 | -------------------------------------------------------------------------------- /src/shared/overrideBaseUri.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import assert from 'assert'; 5 | import { Log } from 'sarif'; 6 | import { overrideBaseUri } from './overrideBaseUri'; 7 | 8 | describe('overrideBaseUri', () => { 9 | function createBasicLog(): Log { 10 | return { 11 | runs: [ 12 | { 13 | originalUriBaseIds: { 14 | SRCROOT_1: { 15 | uri: 'file:///var/jenkins_home/workspace/org/workflow/project/1/', 16 | }, 17 | SRCROOT_2: { 18 | uri: 'file:///var/jenkins_home/workspace/org/workflow/project/2/', 19 | }, 20 | }, 21 | }, 22 | { 23 | originalUriBaseIds: { 24 | SRCROOT_3: { 25 | uri: 'file:///var/jenkins_home/workspace/org/workflow/project/1/', 26 | }, 27 | SRCROOT_4: { 28 | uri: 'file:///var/jenkins_home/workspace/org/workflow/project/2/', 29 | }, 30 | }, 31 | }, 32 | ], 33 | } as unknown as Log; 34 | } 35 | 36 | it('overrides all originalUriBaseIds uri values (most common case)', async () => { 37 | const log = createBasicLog(); 38 | const newBaseUri = 'file:///path/to/project'; 39 | overrideBaseUri(log, newBaseUri); 40 | assert.strictEqual(log.runs![0].originalUriBaseIds!.SRCROOT_1.uri, newBaseUri); 41 | assert.strictEqual(log.runs![0].originalUriBaseIds!.SRCROOT_2.uri, newBaseUri); 42 | assert.strictEqual(log.runs![1].originalUriBaseIds!.SRCROOT_3.uri, newBaseUri); 43 | assert.strictEqual(log.runs![1].originalUriBaseIds!.SRCROOT_4.uri, newBaseUri); 44 | }); 45 | 46 | it('does not throw if log has no runs', async () => { 47 | overrideBaseUri({} as Log, 'file:///path/to/project'); 48 | }); 49 | 50 | it('does not throw if newBaseUri is undefined', async () => { 51 | overrideBaseUri(createBasicLog(), undefined); 52 | }); 53 | 54 | it('does not throw if newBaseUri is empty', async () => { 55 | overrideBaseUri(createBasicLog(), ''); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/shared/overrideBaseUri.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import { Log } from 'sarif'; 5 | 6 | export function overrideBaseUri(log: Log, newBaseUri: string | undefined): void { 7 | if (!newBaseUri) return; 8 | for (const run of log.runs ?? []) { 9 | const originalUriBaseIds = run.originalUriBaseIds ?? {}; 10 | for (const id of Object.keys(originalUriBaseIds)) { 11 | originalUriBaseIds[id].uri = newBaseUri; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/test/mockLog.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import { Log } from 'sarif'; 5 | 6 | export const log = { 7 | version: '2.1.0', 8 | runs: [{ 9 | tool: { 10 | driver: { name: 'Driver' } 11 | }, 12 | results: [{ 13 | message: { 14 | text: 'Message 1' 15 | }, 16 | locations: [{ 17 | physicalLocation: { 18 | artifactLocation: { 19 | uri: '/folder/file.txt', 20 | }, 21 | region: { 22 | startLine: 1, 23 | }, 24 | } 25 | }] 26 | }] 27 | }] 28 | } as Log; 29 | -------------------------------------------------------------------------------- /src/test/mockResultTableStore.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | import { Log } from 'sarif'; 5 | 6 | export const log = { 7 | version: '2.1.0', 8 | runs: [{ 9 | tool: { 10 | driver: { name: 'Driver' } 11 | }, 12 | results: [{ 13 | message: { 14 | text: 'Message 1' 15 | }, 16 | locations: [{ 17 | physicalLocation: { 18 | artifactLocation: { 19 | uri: '/folder/file_1.txt', 20 | } 21 | } 22 | }] 23 | },{ 24 | message: { 25 | text: 'Message 2' 26 | }, 27 | locations: [{ 28 | physicalLocation: { 29 | artifactLocation: { 30 | uri: '/folder/file_2.txt', 31 | } 32 | } 33 | }] 34 | }, { 35 | message: { 36 | text: 'Message 3' 37 | }, 38 | locations: [{ 39 | physicalLocation: { 40 | artifactLocation: { 41 | uri: '/folder/file_2.txt', 42 | } 43 | } 44 | }] 45 | },{ 46 | message: { 47 | text: 'Message 4' 48 | }, 49 | locations: [{ 50 | physicalLocation: { 51 | artifactLocation: { 52 | uri: '/folder/file_3.txt', 53 | } 54 | } 55 | }] 56 | }, { 57 | message: { 58 | text: 'Message 5' 59 | }, 60 | locations: [{ 61 | physicalLocation: { 62 | artifactLocation: { 63 | uri: '/folder/file_3.txt', 64 | } 65 | } 66 | }] 67 | }, { 68 | message: { 69 | text: 'Message 6' 70 | }, 71 | level: 'none', 72 | baselineState: 'new', 73 | _suppression: 'not suppressed', 74 | locations: [{ 75 | physicalLocation: { 76 | artifactLocation: { 77 | uri: '/folder/file_3.txt', 78 | } 79 | } 80 | }] 81 | }] 82 | }] 83 | } as Log; 84 | -------------------------------------------------------------------------------- /src/test/mockVscode.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | // Exceptions to make mocking easier. 5 | /* eslint-disable @typescript-eslint/ban-types */ 6 | /* eslint-disable @typescript-eslint/no-explicit-any */ 7 | 8 | /// 9 | /// Normally 'global.d.ts' auto imports, not sure why it's not working here. 10 | 11 | import { DiagnosticSeverity } from 'vscode'; 12 | import { URI as Uri } from 'vscode-uri'; 13 | import { IndexStore } from '../panel/indexStore'; 14 | import { filtersColumn, filtersRow } from '../shared'; 15 | import { log } from './mockLog'; 16 | import * as path from 'path'; 17 | 18 | global.fetch = async () => ({ json: async () => log }) as unknown as Promise; 19 | global.vscode = { 20 | postMessage: async (message: any) => { 21 | // console.log(`wv2ex message: ${JSON.stringify(message)}`) 22 | await mockVscodeTestFacing.panel_onDidReceiveMessage?.(message); 23 | } 24 | }; 25 | 26 | export const uriForRealFile = Uri.file(path.normalize(path.join(__dirname, '..', '..', 'samples', 'propertyBags.sarif'))); 27 | 28 | export const mockVscodeTestFacing = { 29 | mockFileSystem: undefined as string[] | undefined, 30 | events: [] as string[], 31 | showOpenDialogResult: undefined as Uri[] | undefined, 32 | store: null as IndexStore | null, 33 | activateExtension: async (activate: Function) => { 34 | const context = { 35 | globalState: new Map(), 36 | subscriptions: [], 37 | }; 38 | return await activate(context); 39 | }, 40 | // Internal 41 | panel_onDidReceiveMessage: null as Function | null, 42 | }; 43 | 44 | const registeredCommands: Record = {}; 45 | 46 | export const mockVscode = { 47 | // Extension-facing 48 | commands: { 49 | registerCommand: (name: string, func: Function) => { 50 | registeredCommands[name] = func; 51 | }, 52 | executeCommand: async (name: string, ...args: any[]) => { 53 | const func = registeredCommands[name]; 54 | if (!func) throw new Error(`Command '${name}' not registered.`); 55 | return await func(...args); 56 | } 57 | }, 58 | Diagnostic: class { 59 | constructor(readonly range: Range, readonly message: string, readonly severity?: DiagnosticSeverity) {} 60 | }, 61 | languages: { 62 | createDiagnosticCollection: () => {}, 63 | registerCodeActionsProvider: () => {}, 64 | }, 65 | ProgressLocation: { Notification: 15 }, 66 | Selection: class { 67 | constructor(readonly a: number, readonly b: number, readonly c: number, readonly d: number) {} 68 | }, 69 | TextEditorRevealType: { InCenterIfOutsideViewport: 2 }, 70 | ThemeColor: class {}, 71 | Uri, 72 | ViewColumn: { Two: 2 }, 73 | window: { 74 | createTextEditorDecorationType: () => {}, 75 | createWebviewPanel: () => { 76 | const defaultState = { 77 | filtersRow, 78 | filtersColumn, 79 | }; 80 | 81 | // Simulate the top-level script block of the webview. 82 | (async () => { 83 | mockVscodeTestFacing.store = new IndexStore(defaultState); 84 | const spliceLogsData = { 85 | command: 'spliceLogs', 86 | removed: [], 87 | added: [{ uri: uriForRealFile.toString(true), webviewUri: 'anyValue' }] 88 | }; 89 | await mockVscodeTestFacing.store.onMessage({ data: spliceLogsData } as any); 90 | })(); 91 | 92 | return { 93 | onDidDispose: () => {}, 94 | reveal: () => {}, 95 | webview: { 96 | asWebviewUri: () => '', 97 | onDidReceiveMessage: (f: Function) => mockVscodeTestFacing.panel_onDidReceiveMessage = f, 98 | postMessage: async (message: any) => { 99 | await mockVscodeTestFacing.store!.onMessage({ data: message } as any); 100 | // console.log(`postMessage: ${JSON.stringify(message)}`) 101 | }, 102 | }, 103 | }; 104 | }, 105 | onDidChangeTextEditorSelection: () => {}, 106 | showErrorMessage: (message: any) => console.error(`showErrorMessage: '${message}'`), 107 | showInformationMessage: async (_message: string, ...choices: string[]) => choices[0], // = [0] => 'Locate...' 108 | showOpenDialog: async () => mockVscodeTestFacing.showOpenDialogResult, 109 | showTextDocument: (doc: { uri: any }) => { 110 | mockVscodeTestFacing.events.push(`showTextDocument ${doc.uri}`); 111 | const editor = { 112 | revealRange: () => {}, 113 | set selection(value: any) { 114 | mockVscodeTestFacing.events.push(`selection ${Object.values(value).join(' ')}`); 115 | }, 116 | }; 117 | return editor; 118 | }, 119 | visibleTextEditors: [], 120 | withProgress: (_options: Record, task: Function) => task({ report: () => {} }), 121 | createOutputChannel: () => {}, 122 | registerUriHandler: () => {}, 123 | createStatusBarItem: () => ({ 124 | show: () => {}, 125 | }), 126 | onDidChangeVisibleTextEditors: () => {}, 127 | }, 128 | workspace: { 129 | onDidChangeConfiguration: () => {}, 130 | getConfiguration: () => new Map(), 131 | onDidOpenTextDocument: () => {}, 132 | onDidCloseTextDocument: () => {}, 133 | fs: { 134 | stat: async (uri: Uri) => { 135 | if (mockVscodeTestFacing.mockFileSystem && !mockVscodeTestFacing.mockFileSystem.includes(uri.fsPath)) throw new Error(); 136 | }, 137 | }, 138 | openTextDocument: async (uri: Uri) => { 139 | if (mockVscodeTestFacing.mockFileSystem && !mockVscodeTestFacing.mockFileSystem.includes(uri.fsPath)) throw new Error(); 140 | return { 141 | uri, 142 | lineAt: () => ({ 143 | range: { 144 | start: { line: 0, character: 0 }, 145 | end: { line: 0, character: 2 }, 146 | }, 147 | firstNonWhitespaceCharacterIndex: 1, 148 | }) 149 | }; 150 | }, 151 | findFiles: (include: string, _exclude?: string) => { 152 | if (include === '.sarif/**/*.sarif') { 153 | return [ 154 | uriForRealFile 155 | ]; 156 | } else if (include === '**/file1.txt') { 157 | return [ 158 | Uri.file('/projects/project/file1.txt') 159 | ]; 160 | } else if (include === '**/*') { 161 | return [ 162 | Uri.file('/projects/project/file1.txt') 163 | ]; 164 | } else if (include === '**/file.txt') { 165 | return [ 166 | Uri.file('/x/y/a/file.txt') 167 | ]; 168 | } 169 | return []; 170 | }, 171 | registerTextDocumentContentProvider: () => {}, 172 | textDocuments: [], 173 | onDidCreateFiles: () => {}, 174 | onDidRenameFiles: () => {}, 175 | onDidDeleteFiles: () => {}, 176 | onDidChangeTextDocument: () => {}, 177 | }, 178 | 179 | CodeAction: class { 180 | constructor() { } 181 | }, 182 | CodeActionKind: { QuickFix: { value: 'quickFix' } }, 183 | StatusBarAlignment: { Left: 1, Right: 2 }, 184 | Disposable: class { 185 | dispose() {} 186 | }, 187 | }; 188 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "out", 4 | "module": "commonjs", 5 | "target": "es2019", 6 | "sourceMap": true, 7 | 8 | 9 | "experimentalDecorators": true, 10 | "esModuleInterop": true, 11 | "jsx": "react", 12 | "resolveJsonModule": true, 13 | 14 | "noFallthroughCasesInSwitch": true, 15 | "noImplicitReturns": true, 16 | // "noUnusedLocals": true, 17 | // "noUnusedParameters": true, 18 | "strict": true 19 | }, 20 | "exclude": [ 21 | "node_modules", 22 | ".vscode-test" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | const CopyPlugin = require("copy-webpack-plugin"); 5 | const outputPath = require('path').join(__dirname, 'out'); 6 | 7 | const common = { 8 | resolve: { 9 | extensions: ['.js', '.ts', '.tsx'] // .js is neccesary for transitive imports 10 | }, 11 | module: { 12 | rules: [ 13 | { 14 | test: /\.tsx?$/, 15 | exclude: /node_modules/, 16 | use: [{ 17 | loader: 'ts-loader', 18 | options: { transpileOnly: true }, // 4x speed increase, but no type checks. 19 | }], 20 | }, 21 | { 22 | test: /\.s?css$/, 23 | use: ['style-loader', 'css-loader', 'sass-loader'], 24 | }, 25 | { 26 | test: /\.ttf$/, 27 | type: 'asset/resource' 28 | }, 29 | ] 30 | }, 31 | 32 | devtool: 'source-map', // 'inline-source-map' hits breakpoints more reliability, but inflate file size. 33 | output: { 34 | filename: '[name].js', // Default, consider omitting. 35 | path: outputPath, 36 | }, 37 | 38 | stats: { 39 | all: false, 40 | assets: true, 41 | builtAt: true, 42 | errors: true, 43 | performance: true, 44 | timings: true, 45 | }, 46 | }; 47 | 48 | module.exports = [ 49 | { 50 | ...common, 51 | name: 'Panel', // Ordered 1st for devServer. https://github.com/webpack/webpack/issues/1849 52 | entry: { panel: './src/panel/index.tsx' }, 53 | output: { 54 | ...common.output, 55 | libraryTarget: 'umd', 56 | globalObject: 'this', 57 | }, 58 | devServer : { 59 | client: { 60 | overlay: { 61 | errors: true, 62 | warnings: false, // Workaround for: "Module not found: Error: Can't resolve 'applicationinsights-native-metrics' in '.../node_modules/applicationinsights/out/AutoCollection'" 63 | }, 64 | }, 65 | static: { 66 | directory: __dirname, // Otherwise will default to /public 67 | }, 68 | port: 8000 69 | }, 70 | performance: { 71 | hints: 'warning', 72 | maxAssetSize: 400 * 1024, 73 | maxEntrypointSize: 400 * 1024, 74 | }, 75 | plugins: [ 76 | new CopyPlugin({ 77 | patterns: [ 'src/panel/init.js' ], 78 | }), 79 | ], 80 | }, 81 | { 82 | ...common, 83 | name: 'Context', 84 | entry: { context: './src/extension/index.ts' }, 85 | output: { 86 | ...common.output, 87 | libraryTarget: 'commonjs2', 88 | devtoolModuleFilenameTemplate: '../[resource-path]' // https://code.visualstudio.com/api/working-with-extensions/bundling-extension#configure-webpack 89 | }, 90 | target: 'node', 91 | externals: { 92 | fsevents: 'fsevents', 93 | vscode: 'commonjs vscode' // the vscode-module is created on-the-fly and must be excluded. 94 | }, 95 | }, 96 | ]; 97 | --------------------------------------------------------------------------------