├── .eslintignore ├── .vscode ├── settings.json ├── extensions.json ├── tasks.json └── launch.json ├── .prettierrc.json ├── images ├── settings.png ├── breadcrumbs-1.png ├── breadcrumbs-2.png ├── demo-basic-usage.gif ├── demo-collaboration.gif └── demo-semgrep-import.gif ├── .vscodeignore ├── resources ├── reactions │ ├── eyes.png │ ├── heart.png │ ├── laugh.png │ ├── confused.png │ ├── hooray.png │ ├── rocket.png │ ├── thumbs_up.png │ └── thumbs_down.png ├── security_notes_logo.png ├── edit.svg ├── edit_inverse.svg ├── close.svg ├── close_inverse.svg └── security_notes_icon.svg ├── src ├── models │ ├── noteStatus.ts │ ├── toolFinding.ts │ ├── breadcrumb.ts │ └── noteComment.ts ├── controllers │ └── comments.ts ├── webviews │ ├── assets │ │ ├── reset.css │ │ ├── main.css │ │ ├── importToolResults.js │ │ ├── exportNotes.js │ │ ├── vscode.css │ │ ├── breadcrumbs.css │ │ └── breadcrumbs.js │ ├── import-tool-results │ │ └── importToolResultsWebview.ts │ ├── export-notes │ │ └── exportNotesWebview.ts │ └── breadcrumbs │ │ └── breadcrumbsWebview.ts ├── breadcrumbs │ ├── format.ts │ ├── export.ts │ ├── store.ts │ └── commands.ts ├── handlers │ └── reaction.ts ├── parsers │ ├── gosec.ts │ ├── semgrep.ts │ ├── bandit.ts │ ├── brakeman.ts │ └── checkov.ts ├── persistence │ ├── local-db │ │ ├── index.ts │ │ └── breadcrumbs.ts │ ├── serialization │ │ ├── serializer.ts │ │ └── deserializer.ts │ └── remote-db │ │ └── index.ts ├── utils │ └── index.ts ├── reactions │ └── resource.ts ├── helpers.ts └── extension.ts ├── .editorconfig ├── tsconfig.json ├── .eslintrc.js ├── eslintrc.json ├── LICENSE.md ├── .gitignore ├── README.md └── package.json /.eslintignore: -------------------------------------------------------------------------------- 1 | # Ignore dependency directories 2 | node_modules/* 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.insertSpaces": false 3 | } 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 88, 3 | "singleQuote": true, 4 | "trailingComma": "all" 5 | } 6 | -------------------------------------------------------------------------------- /images/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RefactorSecurity/vscode-security-notes/HEAD/images/settings.png -------------------------------------------------------------------------------- /images/breadcrumbs-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RefactorSecurity/vscode-security-notes/HEAD/images/breadcrumbs-1.png -------------------------------------------------------------------------------- /images/breadcrumbs-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RefactorSecurity/vscode-security-notes/HEAD/images/breadcrumbs-2.png -------------------------------------------------------------------------------- /images/demo-basic-usage.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RefactorSecurity/vscode-security-notes/HEAD/images/demo-basic-usage.gif -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .eslintignore 2 | .gitignore 3 | .vscode/ 4 | eslintrc.json 5 | images/ 6 | package.json 7 | tsconfig.json 8 | README.md -------------------------------------------------------------------------------- /images/demo-collaboration.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RefactorSecurity/vscode-security-notes/HEAD/images/demo-collaboration.gif -------------------------------------------------------------------------------- /resources/reactions/eyes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RefactorSecurity/vscode-security-notes/HEAD/resources/reactions/eyes.png -------------------------------------------------------------------------------- /resources/reactions/heart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RefactorSecurity/vscode-security-notes/HEAD/resources/reactions/heart.png -------------------------------------------------------------------------------- /resources/reactions/laugh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RefactorSecurity/vscode-security-notes/HEAD/resources/reactions/laugh.png -------------------------------------------------------------------------------- /images/demo-semgrep-import.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RefactorSecurity/vscode-security-notes/HEAD/images/demo-semgrep-import.gif -------------------------------------------------------------------------------- /resources/reactions/confused.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RefactorSecurity/vscode-security-notes/HEAD/resources/reactions/confused.png -------------------------------------------------------------------------------- /resources/reactions/hooray.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RefactorSecurity/vscode-security-notes/HEAD/resources/reactions/hooray.png -------------------------------------------------------------------------------- /resources/reactions/rocket.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RefactorSecurity/vscode-security-notes/HEAD/resources/reactions/rocket.png -------------------------------------------------------------------------------- /resources/reactions/thumbs_up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RefactorSecurity/vscode-security-notes/HEAD/resources/reactions/thumbs_up.png -------------------------------------------------------------------------------- /resources/security_notes_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RefactorSecurity/vscode-security-notes/HEAD/resources/security_notes_logo.png -------------------------------------------------------------------------------- /resources/reactions/thumbs_down.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RefactorSecurity/vscode-security-notes/HEAD/resources/reactions/thumbs_down.png -------------------------------------------------------------------------------- /src/models/noteStatus.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | export enum NoteStatus { 4 | TODO = 'TODO', 5 | Vulnerable = 'Vulnerable', 6 | NotVulnerable = 'Not Vulnerable', 7 | } 8 | -------------------------------------------------------------------------------- /src/controllers/comments.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as vscode from 'vscode'; 4 | 5 | export const commentController = vscode.comments.createCommentController( 6 | 'security-notes', 7 | 'Security Notes', 8 | ); 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | 9 | [*.{js,json}] 10 | indent_size = 2 11 | indent_style = space 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /src/models/toolFinding.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as vscode from 'vscode'; 4 | 5 | class ToolFinding { 6 | constructor( 7 | public uri: vscode.Uri, 8 | public range: vscode.Range, 9 | public text: string, 10 | ) {} 11 | } 12 | 13 | export { ToolFinding }; 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2020", 5 | "lib": ["es2020"], 6 | "outDir": "out", 7 | "sourceMap": true, 8 | "strict": true, 9 | "rootDir": "src" 10 | }, 11 | "exclude": ["node_modules", ".vscode-test"] 12 | } 13 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | 5 | // List of extensions which should be recommended for users of this workspace. 6 | "recommendations": ["dbaeumer.vscode-eslint"] 7 | } 8 | -------------------------------------------------------------------------------- /src/webviews/assets/reset.css: -------------------------------------------------------------------------------- 1 | html { 2 | box-sizing: border-box; 3 | font-size: 13px; 4 | } 5 | 6 | *, 7 | *:before, 8 | *:after { 9 | box-sizing: inherit; 10 | } 11 | 12 | body, 13 | h1, 14 | h2, 15 | h3, 16 | h4, 17 | h5, 18 | h6, 19 | ol, 20 | ul { 21 | margin: 0; 22 | padding: 0; 23 | font-weight: normal; 24 | } 25 | 26 | img { 27 | max-width: 100%; 28 | height: auto; 29 | } 30 | 31 | p { 32 | line-height: 25px; /* within paragraph */ 33 | font-weight: normal; 34 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": "$tsc-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never" 13 | }, 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /resources/edit.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /**@type {import('eslint').Linter.Config} */ 2 | // eslint-disable-next-line no-undef 3 | module.exports = { 4 | root: true, 5 | parser: '@typescript-eslint/parser', 6 | plugins: ['@typescript-eslint'], 7 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], 8 | rules: { 9 | semi: [2, 'always'], 10 | '@typescript-eslint/no-unused-vars': 0, 11 | '@typescript-eslint/no-explicit-any': 0, 12 | '@typescript-eslint/explicit-module-boundary-types': 0, 13 | '@typescript-eslint/no-non-null-assertion': 0, 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /resources/edit_inverse.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/models/breadcrumb.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as vscode from 'vscode'; 4 | 5 | export interface Crumb { 6 | id: string; 7 | trailId: string; 8 | uri: vscode.Uri; 9 | range: vscode.Range; 10 | snippet: string; 11 | note?: string; 12 | createdAt: string; 13 | } 14 | 15 | export interface Trail { 16 | id: string; 17 | name: string; 18 | description?: string; 19 | createdAt: string; 20 | updatedAt: string; 21 | crumbs: Crumb[]; 22 | } 23 | 24 | export interface BreadcrumbState { 25 | activeTrailId?: string; 26 | trails: Trail[]; 27 | } 28 | -------------------------------------------------------------------------------- /resources/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "env": { 4 | "browser": true, 5 | "commonjs": true, 6 | "es6": true, 7 | "jest": true, 8 | "node": true 9 | }, 10 | "extends": ["airbnb", "plugin:prettier/recommended"], 11 | "rules": { 12 | "no-nested-ternary": "off", 13 | "no-shadow": "off", 14 | "no-underscore-dangle": [ 15 | "error", 16 | { 17 | "allow": ["_id"] 18 | } 19 | ], 20 | "import/no-extraneous-dependencies": [ 21 | "error", 22 | { 23 | "devDependencies": ["**/*.test.js", "**/*.stories.js", ".storybook/**"] 24 | } 25 | ] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /resources/close_inverse.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | -------------------------------------------------------------------------------- /src/breadcrumbs/format.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as vscode from 'vscode'; 4 | 5 | export const formatRangeLabel = (range: vscode.Range) => { 6 | const startLine = range.start.line + 1; 7 | const endLine = range.end.line + 1; 8 | if (startLine === endLine) { 9 | return `${startLine}`; 10 | } 11 | return `${startLine}-${endLine}`; 12 | }; 13 | 14 | export const snippetPreview = (snippet: string, maxLength = 80) => { 15 | const trimmed = snippet.trim(); 16 | if (!trimmed.length) { 17 | return '(empty selection)'; 18 | } 19 | const preview = trimmed.split('\n')[0].trim(); 20 | return preview.length > maxLength ? `${preview.slice(0, maxLength - 3)}...` : preview; 21 | }; 22 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "runtimeExecutable": "${execPath}", 13 | "args": ["--extensionDevelopmentPath=${workspaceFolder}"], 14 | "outFiles": ["${workspaceFolder}/out/**/*.js"], 15 | "preLaunchTask": "npm: watch" 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /src/webviews/assets/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: transparent; 3 | } 4 | 5 | .color-list { 6 | list-style: none; 7 | padding: 0; 8 | } 9 | 10 | .color-entry { 11 | width: 100%; 12 | display: flex; 13 | margin-bottom: 0.4em; 14 | border: 1px solid var(--vscode-input-border); 15 | } 16 | 17 | .color-preview { 18 | width: 2em; 19 | height: 2em; 20 | } 21 | 22 | .color-preview:hover { 23 | outline: inset white; 24 | } 25 | 26 | .color-input { 27 | display: block; 28 | flex: 1; 29 | width: 100%; 30 | color: var(--vscode-input-foreground); 31 | background-color: var(--vscode-input-background); 32 | border: none; 33 | padding: 0 0.6em; 34 | } 35 | 36 | .process-file-button { 37 | display: block; 38 | border: none; 39 | margin: 0 auto; 40 | } 41 | -------------------------------------------------------------------------------- /src/handlers/reaction.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as vscode from 'vscode'; 4 | import { NoteComment } from '../models/noteComment'; 5 | 6 | export const reactionHandler = async ( 7 | c: vscode.Comment, 8 | reaction: vscode.CommentReaction, 9 | ) => { 10 | const comment = c as NoteComment; 11 | if (!comment.parent) { 12 | return; 13 | } 14 | 15 | comment.parent.comments = comment.parent.comments.map((cmt) => { 16 | if ((cmt as NoteComment).id === comment.id) { 17 | const index = cmt.reactions!.findIndex((r) => r.label === reaction.label); 18 | cmt.reactions!.splice(index, 1, { 19 | ...reaction, 20 | count: reaction.authorHasReacted ? reaction.count - 1 : reaction.count + 1, 21 | authorHasReacted: !reaction.authorHasReacted, 22 | }); 23 | } 24 | 25 | return cmt; 26 | }); 27 | }; 28 | -------------------------------------------------------------------------------- /src/models/noteComment.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as vscode from 'vscode'; 4 | 5 | let commentId = 1; 6 | 7 | export class NoteComment implements vscode.Comment { 8 | id: number; 9 | label: string | undefined; 10 | savedBody: string | vscode.MarkdownString; // for the Cancel button 11 | constructor( 12 | public body: string | vscode.MarkdownString, 13 | public mode: vscode.CommentMode, 14 | public author: vscode.CommentAuthorInformation, 15 | public parent?: vscode.CommentThread, 16 | public reactions: vscode.CommentReaction[] = [], 17 | public contextValue?: string, 18 | public timestamp?: Date, 19 | ) { 20 | this.id = ++commentId; 21 | this.savedBody = this.body; 22 | if (timestamp) { 23 | this.timestamp = new Date(timestamp); 24 | } else { 25 | this.timestamp = new Date(); 26 | } 27 | this.contextValue = contextValue; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/parsers/gosec.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as vscode from 'vscode'; 4 | import { ToolFinding } from '../models/toolFinding'; 5 | 6 | class GosecParser { 7 | static parse(fileContent: string) { 8 | const toolFindings: ToolFinding[] = []; 9 | 10 | try { 11 | const gosecFindings = JSON.parse(fileContent).Issues; 12 | gosecFindings.map((gosecFinding: any) => { 13 | // uri 14 | const uri = vscode.Uri.file(gosecFinding.file); 15 | 16 | // range 17 | const line = gosecFinding.line; 18 | const range = new vscode.Range(line - 1, 0, line - 1, 0); 19 | 20 | // instantiate tool finding and add to list 21 | const toolFinding = new ToolFinding(uri, range, gosecFinding.details); 22 | toolFindings.push(toolFinding); 23 | }); 24 | } catch { 25 | /* empty */ 26 | } 27 | 28 | return toolFindings; 29 | } 30 | } 31 | 32 | export { GosecParser }; 33 | -------------------------------------------------------------------------------- /src/persistence/local-db/index.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as fs from 'fs'; 4 | import { Serializer } from '../serialization/serializer'; 5 | import { Deserializer } from '../serialization/deserializer'; 6 | import { CommentThread } from 'vscode'; 7 | import { getLocalDbFilePath } from '../../utils'; 8 | 9 | const persistenceFile = getLocalDbFilePath(); 10 | 11 | export const saveNotesToFile = (noteMap: Map) => { 12 | // Avoid creating persistence file if no notes were created 13 | if (!fs.existsSync(persistenceFile) && !noteMap.size) { 14 | return; 15 | } 16 | fs.writeFileSync(persistenceFile, JSON.stringify(Serializer.serialize(noteMap))); 17 | }; 18 | 19 | export const loadNotesFromFile = (): CommentThread[] => { 20 | // Check if persistence file exists and load comments 21 | if (fs.existsSync(persistenceFile)) { 22 | const jsonFile = fs.readFileSync(persistenceFile).toString(); 23 | const persistedThreads = JSON.parse(jsonFile); 24 | return Deserializer.deserialize(persistedThreads); 25 | } else { 26 | return []; 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /src/parsers/semgrep.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as vscode from 'vscode'; 4 | import { ToolFinding } from '../models/toolFinding'; 5 | import { relativePathToFull } from '../utils'; 6 | 7 | class SemgrepParser { 8 | static parse(fileContent: string) { 9 | const toolFindings: ToolFinding[] = []; 10 | 11 | try { 12 | const semgrepFindings = JSON.parse(fileContent).results; 13 | semgrepFindings.map((semgrepFinding: any) => { 14 | // uri 15 | const uri = vscode.Uri.file(relativePathToFull(semgrepFinding.path)); 16 | 17 | // range 18 | const range = new vscode.Range( 19 | semgrepFinding.start.line - 1, 20 | 0, 21 | semgrepFinding.end.line - 1, 22 | 0, 23 | ); 24 | 25 | // instantiate tool finding and add to list 26 | const toolFinding = new ToolFinding(uri, range, semgrepFinding.extra.message); 27 | toolFindings.push(toolFinding); 28 | }); 29 | } catch { 30 | /* empty */ 31 | } 32 | 33 | return toolFindings; 34 | } 35 | } 36 | 37 | export { SemgrepParser }; 38 | -------------------------------------------------------------------------------- /src/parsers/bandit.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as vscode from 'vscode'; 4 | import { ToolFinding } from '../models/toolFinding'; 5 | import { relativePathToFull } from '../utils'; 6 | 7 | class BanditParser { 8 | static parse(fileContent: string) { 9 | const toolFindings: ToolFinding[] = []; 10 | 11 | try { 12 | const banditFindings = JSON.parse(fileContent).results; 13 | banditFindings.map((banditFinding: any) => { 14 | // uri 15 | const uri = vscode.Uri.file(relativePathToFull(banditFinding.filename)); 16 | 17 | // range 18 | const lineRange = banditFinding.line_range; 19 | const range = new vscode.Range( 20 | lineRange[0] - 1, 21 | 0, 22 | (lineRange[1] ? lineRange[1] : lineRange[0]) - 1, 23 | 0, 24 | ); 25 | 26 | // instantiate tool finding and add to list 27 | const toolFinding = new ToolFinding(uri, range, banditFinding.issue_text); 28 | toolFindings.push(toolFinding); 29 | }); 30 | } catch { 31 | /* empty */ 32 | } 33 | 34 | return toolFindings; 35 | } 36 | } 37 | 38 | export { BanditParser }; 39 | -------------------------------------------------------------------------------- /src/webviews/assets/importToolResults.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | 3 | // This script will be run within the webview itself 4 | // It cannot access the main VS Code APIs directly. 5 | (function () { 6 | const vscode = acquireVsCodeApi(); 7 | 8 | document 9 | .querySelector('.process-file-button') 10 | .addEventListener('click', () => onButtonClicked()); 11 | 12 | function onButtonClicked() { 13 | let toolSelect = document.getElementById('toolSelect'); 14 | let toolName = toolSelect.options[toolSelect.selectedIndex].value; 15 | 16 | let selectedFile = document.getElementById('fileInput').files[0]; 17 | readFile(selectedFile).then((fileContent) => { 18 | vscode.postMessage({ type: 'processToolFile', toolName, fileContent }), 19 | (document.getElementById('fileInput').value = ''); 20 | }); 21 | } 22 | 23 | async function readFile(file) { 24 | let fileContent = await new Promise((resolve) => { 25 | let fileReader = new FileReader(); 26 | fileReader.onload = (e) => resolve(fileReader.result); 27 | fileReader.readAsText(file); 28 | }); 29 | 30 | return fileContent; 31 | } 32 | })(); 33 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) Microsoft Corporation, (2023) Refactor Security 2 | 3 | All rights reserved. 4 | 5 | MIT License 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation 8 | files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, 9 | modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 15 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS 16 | BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT 17 | OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /src/parsers/brakeman.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as vscode from 'vscode'; 4 | import { ToolFinding } from '../models/toolFinding'; 5 | import { relativePathToFull } from '../utils'; 6 | 7 | class BrakemanParser { 8 | static parse(fileContent: string) { 9 | const toolFindings: ToolFinding[] = []; 10 | 11 | try { 12 | const brakemanFindings = JSON.parse(fileContent).warnings; 13 | brakemanFindings.map((brakemanFinding: any) => { 14 | // uri 15 | const uri = vscode.Uri.file(relativePathToFull(brakemanFinding.file)); 16 | 17 | // range 18 | const range = new vscode.Range( 19 | brakemanFinding.line - 1, 20 | 0, 21 | brakemanFinding.line - 1, 22 | 0, 23 | ); 24 | 25 | // instantiate tool finding and add to list 26 | const toolFinding = new ToolFinding( 27 | uri, 28 | range, 29 | `${brakemanFinding.warning_type}: ${brakemanFinding.message}`, 30 | ); 31 | toolFindings.push(toolFinding); 32 | }); 33 | } catch { 34 | /* empty */ 35 | } 36 | 37 | return toolFindings; 38 | } 39 | } 40 | 41 | export { BrakemanParser }; 42 | -------------------------------------------------------------------------------- /src/parsers/checkov.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as vscode from 'vscode'; 4 | import { ToolFinding } from '../models/toolFinding'; 5 | import { relativePathToFull } from '../utils'; 6 | 7 | class CheckovParser { 8 | static parse(fileContent: string) { 9 | const toolFindings: ToolFinding[] = []; 10 | 11 | try { 12 | const checkovCheckTypes = JSON.parse(fileContent); 13 | checkovCheckTypes.map((checkovCheckType: any) => { 14 | const checkovFindings = checkovCheckType.results.failed_checks; 15 | checkovFindings.map((checkovFinding: any) => { 16 | // uri 17 | const uri = vscode.Uri.file(relativePathToFull(checkovFinding.file_path)); 18 | 19 | // range 20 | const range = new vscode.Range( 21 | checkovFinding.file_line_range[0] - 1, 22 | 0, 23 | checkovFinding.file_line_range[1] - 1, 24 | 0, 25 | ); 26 | 27 | // instantiate tool finding and add to list 28 | const toolFinding = new ToolFinding(uri, range, checkovFinding.check_name); 29 | toolFindings.push(toolFinding); 30 | }); 31 | }); 32 | } catch { 33 | /* empty */ 34 | } 35 | 36 | return toolFindings; 37 | } 38 | } 39 | 40 | export { CheckovParser }; 41 | -------------------------------------------------------------------------------- /src/webviews/assets/exportNotes.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | 3 | // This script will be run within the webview itself 4 | // It cannot access the main VS Code APIs directly. 5 | (function () { 6 | const vscode = acquireVsCodeApi(); 7 | 8 | document 9 | .querySelector('.export-notes-button') 10 | .addEventListener('click', () => onButtonClicked()); 11 | 12 | function onButtonClicked() { 13 | // selected notes 14 | let vulnerable = document.getElementById('vulnerable-notes').checked; 15 | let notVulnerable = document.getElementById('not-vulnerable-notes').checked; 16 | let todo = document.getElementById('todo-notes').checked; 17 | let noStatus = document.getElementById('no-status-notes').checked; 18 | let status = { 19 | vulnerable, 20 | notVulnerable, 21 | todo, 22 | noStatus, 23 | }; 24 | 25 | // additional options 26 | let includeCodeSnippet = document.getElementById('include-code-snippet').checked; 27 | let includeReplies = document.getElementById('include-note-replies').checked; 28 | let includeAuthors = document.getElementById('include-authors').checked; 29 | let options = { 30 | includeCodeSnippet, 31 | includeReplies, 32 | includeAuthors, 33 | }; 34 | 35 | // export format 36 | let formatSelect = document.getElementById('format-select'); 37 | let format = formatSelect.options[formatSelect.selectedIndex].value; 38 | 39 | vscode.postMessage({ type: 'exportNotes', status, options, format }); 40 | } 41 | })(); 42 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as vscode from 'vscode'; 4 | import * as path from 'path'; 5 | import { platform } from 'os'; 6 | 7 | export const isWindows = () => { 8 | return platform() === 'win32'; 9 | }; 10 | 11 | export const pathToPosix = (aPath: string) => { 12 | return aPath.split(path.sep).join(path.posix.sep); 13 | }; 14 | 15 | export const pathToWin32 = (aPath: string) => { 16 | return aPath.split(path.win32.sep).join(path.win32.sep); 17 | }; 18 | 19 | export const getWorkspacePath = () => { 20 | if (vscode.workspace.workspaceFolders) { 21 | return vscode.workspace.workspaceFolders[0].uri.fsPath; 22 | } else { 23 | return ''; 24 | } 25 | }; 26 | 27 | export const getLocalDbFilePath = () => { 28 | const localDbFilePath = vscode.workspace 29 | .getConfiguration('security-notes') 30 | .get('localDatabase', '.security-notes.json'); 31 | if (path.isAbsolute(localDbFilePath)) { 32 | return localDbFilePath; 33 | } else { 34 | return relativePathToFull(localDbFilePath); 35 | } 36 | }; 37 | 38 | export const getBreadcrumbsDbFilePath = () => { 39 | const breadcrumbsDbFilePath = getSetting( 40 | 'breadcrumbs.localDatabase', 41 | '.security-notes-breadcrumbs.json', 42 | ); 43 | if (path.isAbsolute(breadcrumbsDbFilePath)) { 44 | return breadcrumbsDbFilePath; 45 | } 46 | return relativePathToFull(breadcrumbsDbFilePath); 47 | }; 48 | 49 | export const relativePathToFull = (aPath: string, basePath?: string) => { 50 | if (basePath) { 51 | return path.join(basePath, aPath); 52 | } 53 | return path.join(getWorkspacePath(), aPath); 54 | }; 55 | 56 | export const fullPathToRelative = (aPath: string, basePath?: string) => { 57 | if (basePath) { 58 | return path.relative(basePath, aPath); 59 | } 60 | return path.relative(getWorkspacePath(), aPath); 61 | }; 62 | 63 | export const getSetting = (settingName: string, defaultValue?: any) => { 64 | return vscode.workspace 65 | .getConfiguration('security-notes') 66 | .get(settingName, defaultValue); 67 | }; 68 | -------------------------------------------------------------------------------- /src/persistence/serialization/serializer.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { Comment, CommentReaction, CommentThread, Range } from 'vscode'; 4 | import { fullPathToRelative, isWindows, pathToPosix, pathToWin32 } from '../../utils'; 5 | import { Resource } from '../../reactions/resource'; 6 | 7 | export class Serializer { 8 | static serializeReaction(reaction: CommentReaction) { 9 | let iconPath = fullPathToRelative( 10 | typeof reaction.iconPath === 'string' 11 | ? reaction.iconPath 12 | : reaction.iconPath.fsPath, 13 | Resource.extensionPath, 14 | ); 15 | if (isWindows()) { 16 | iconPath = pathToPosix(iconPath); 17 | } 18 | return { 19 | count: reaction.count, 20 | iconPath: iconPath, 21 | label: reaction.label, 22 | }; 23 | } 24 | 25 | static serializeComment(comment: Comment): any { 26 | const serializedReactions: any[] = []; 27 | if (comment.reactions) { 28 | comment.reactions.map((reaction) => { 29 | serializedReactions.push(this.serializeReaction(reaction)); 30 | }); 31 | } 32 | return { 33 | author: comment.author.name, 34 | body: comment.body, 35 | reactions: serializedReactions, 36 | timestamp: comment.timestamp, 37 | }; 38 | } 39 | 40 | static serializeRange(range: Range): any { 41 | return { 42 | startLine: range.start.line, 43 | endLine: range.end.line, 44 | }; 45 | } 46 | 47 | public static serializeThread(thread: CommentThread): any { 48 | const serializedComments: any[] = []; 49 | thread.comments.forEach((comment) => { 50 | serializedComments.push(this.serializeComment(comment)); 51 | }); 52 | let uri = fullPathToRelative(thread.uri.fsPath); 53 | if (isWindows()) { 54 | uri = pathToPosix(uri); 55 | } 56 | return { 57 | range: this.serializeRange(thread.range), 58 | uri: uri, 59 | comments: serializedComments, 60 | id: thread.contextValue, 61 | }; 62 | } 63 | 64 | public static serialize(noteList: Map) { 65 | const serializedThreads: any[] = []; 66 | noteList.forEach((thread) => { 67 | serializedThreads.push(this.serializeThread(thread)); 68 | }); 69 | return serializedThreads; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/webviews/assets/vscode.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --container-paddding: 20px; 3 | --input-padding-vertical: 6px; 4 | --input-padding-horizontal: 4px; 5 | --input-margin-vertical: 4px; 6 | --input-margin-horizontal: 0; 7 | } 8 | 9 | body { 10 | padding: 0 var(--container-paddding); 11 | color: var(--vscode-foreground); 12 | font-size: var(--vscode-font-size); 13 | font-weight: var(--vscode-font-weight); 14 | font-family: var(--vscode-font-family); 15 | background-color: var(--vscode-editor-background); 16 | } 17 | 18 | ol, 19 | ul { 20 | padding-left: var(--container-paddding); 21 | } 22 | 23 | body > *, 24 | form > * { 25 | margin-block-start: var(--input-margin-vertical); 26 | margin-block-end: var(--input-margin-vertical); 27 | } 28 | 29 | *:focus { 30 | outline-color: var(--vscode-focusBorder) !important; 31 | } 32 | 33 | a { 34 | color: var(--vscode-textLink-foreground); 35 | } 36 | 37 | a:hover, 38 | a:active { 39 | color: var(--vscode-textLink-activeForeground); 40 | } 41 | 42 | code { 43 | font-size: var(--vscode-editor-font-size); 44 | font-family: var(--vscode-editor-font-family); 45 | } 46 | 47 | button { 48 | border: none; 49 | padding: var(--input-padding-vertical) var(--input-padding-horizontal); 50 | width: 100%; 51 | text-align: center; 52 | outline: 1px solid transparent; 53 | outline-offset: 2px !important; 54 | color: var(--vscode-button-foreground); 55 | background: var(--vscode-button-background); 56 | } 57 | 58 | button:hover { 59 | cursor: pointer; 60 | background: var(--vscode-button-hoverBackground); 61 | } 62 | 63 | button:focus { 64 | outline-color: var(--vscode-focusBorder); 65 | } 66 | 67 | button.secondary { 68 | color: var(--vscode-button-secondaryForeground); 69 | background: var(--vscode-button-secondaryBackground); 70 | } 71 | 72 | button.secondary:hover { 73 | background: var(--vscode-button-secondaryHoverBackground); 74 | } 75 | 76 | input:not([type='checkbox']):not([type='radio']), 77 | select, 78 | textarea { 79 | display: block; 80 | width: 100%; 81 | border: none; 82 | font-family: var(--vscode-font-family); 83 | padding: var(--input-padding-vertical) var(--input-padding-horizontal); 84 | color: var(--vscode-input-foreground); 85 | outline-color: var(--vscode-input-border); 86 | background-color: var(--vscode-input-background); 87 | } 88 | 89 | input::placeholder, 90 | textarea::placeholder { 91 | color: var(--vscode-input-placeholderForeground); 92 | } 93 | -------------------------------------------------------------------------------- /src/reactions/resource.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as vscode from 'vscode'; 4 | import * as path from 'path'; 5 | 6 | export class Resource { 7 | static extensionPath: string; 8 | static icons: any; 9 | 10 | static initialize(context: vscode.ExtensionContext) { 11 | Resource.extensionPath = context.asAbsolutePath(''); 12 | Resource.icons = { 13 | reactions: { 14 | THUMBS_UP: context.asAbsolutePath( 15 | path.join('resources', 'reactions', 'thumbs_up.png'), 16 | ), 17 | THUMBS_DOWN: context.asAbsolutePath( 18 | path.join('resources', 'reactions', 'thumbs_down.png'), 19 | ), 20 | CONFUSED: context.asAbsolutePath( 21 | path.join('resources', 'reactions', 'confused.png'), 22 | ), 23 | EYES: context.asAbsolutePath(path.join('resources', 'reactions', 'eyes.png')), 24 | HEART: context.asAbsolutePath(path.join('resources', 'reactions', 'heart.png')), 25 | HOORAY: context.asAbsolutePath( 26 | path.join('resources', 'reactions', 'hooray.png'), 27 | ), 28 | LAUGH: context.asAbsolutePath(path.join('resources', 'reactions', 'laugh.png')), 29 | ROCKET: context.asAbsolutePath( 30 | path.join('resources', 'reactions', 'rocket.png'), 31 | ), 32 | }, 33 | }; 34 | } 35 | } 36 | 37 | export function getReactionGroup(): { 38 | title: string; 39 | label: string; 40 | icon: vscode.Uri; 41 | }[] { 42 | const ret = [ 43 | { 44 | title: 'CONFUSED', 45 | label: '😕', 46 | icon: Resource.icons.reactions.CONFUSED, 47 | }, 48 | { 49 | title: 'EYES', 50 | label: '👀', 51 | icon: Resource.icons.reactions.EYES, 52 | }, 53 | { 54 | title: 'HEART', 55 | label: '❤️', 56 | icon: Resource.icons.reactions.HEART, 57 | }, 58 | { 59 | title: 'HOORAY', 60 | label: '🎉', 61 | icon: Resource.icons.reactions.HOORAY, 62 | }, 63 | { 64 | title: 'LAUGH', 65 | label: '😄', 66 | icon: Resource.icons.reactions.LAUGH, 67 | }, 68 | { 69 | title: 'ROCKET', 70 | label: '🚀', 71 | icon: Resource.icons.reactions.ROCKET, 72 | }, 73 | { 74 | title: 'THUMBS_DOWN', 75 | label: '👎', 76 | icon: Resource.icons.reactions.THUMBS_DOWN, 77 | }, 78 | { 79 | title: 'THUMBS_UP', 80 | label: '👍', 81 | icon: Resource.icons.reactions.THUMBS_UP, 82 | }, 83 | ]; 84 | 85 | return ret; 86 | } 87 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | # macOS files 133 | **/.DS_Store 134 | 135 | # Build files 136 | **/*.vsix -------------------------------------------------------------------------------- /src/persistence/serialization/deserializer.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as vscode from 'vscode'; 4 | import { CommentReaction, CommentThread, Range } from 'vscode'; 5 | import { NoteComment } from '../../models/noteComment'; 6 | import { commentController } from '../../controllers/comments'; 7 | import { isWindows, pathToWin32, relativePathToFull } from '../../utils'; 8 | import { Resource } from '../../reactions/resource'; 9 | 10 | export class Deserializer { 11 | static deserializeReaction(reaction: any): CommentReaction { 12 | let iconPath = relativePathToFull(reaction.iconPath, Resource.extensionPath); 13 | if (isWindows()) { 14 | iconPath = pathToWin32(iconPath); 15 | } 16 | return { 17 | count: reaction.count, 18 | iconPath: iconPath, 19 | label: reaction.label, 20 | authorHasReacted: false, 21 | }; 22 | } 23 | 24 | static deserializeComment( 25 | comment: any, 26 | parent: CommentThread | undefined, 27 | ): NoteComment { 28 | const deserializedReactions: any[] = []; 29 | comment.reactions.forEach((reaction: any) => { 30 | deserializedReactions.push(this.deserializeReaction(reaction)); 31 | }); 32 | const newComment = new NoteComment( 33 | comment.body, 34 | vscode.CommentMode.Preview, 35 | { name: comment.author }, 36 | parent, 37 | deserializedReactions, 38 | parent && parent.comments.length ? 'canDelete' : undefined, 39 | comment.timestamp, 40 | ); 41 | return newComment; 42 | } 43 | 44 | static deserializeRange(range: any): Range { 45 | return new Range(range.startLine, 0, range.endLine, 0); 46 | } 47 | 48 | static deserializeThread(thread: any): CommentThread { 49 | let uri = relativePathToFull(thread.uri); 50 | if (isWindows()) { 51 | uri = pathToWin32(uri); 52 | } 53 | const newThread = commentController.createCommentThread( 54 | vscode.Uri.file(uri), 55 | this.deserializeRange(thread.range), 56 | [], 57 | ); 58 | newThread.contextValue = thread.id; 59 | const deserializedComments: NoteComment[] = []; 60 | thread.comments.forEach((comment: any) => { 61 | deserializedComments.push(this.deserializeComment(comment, newThread)); 62 | }); 63 | newThread.comments = deserializedComments; 64 | 65 | // mark all comments as deletable, except for the first one 66 | newThread.comments 67 | .slice(1) 68 | .forEach((comment) => (comment.contextValue = 'canDelete')); 69 | 70 | return newThread; 71 | } 72 | 73 | public static deserialize(deserializednoteList: any[]): CommentThread[] { 74 | const deserializedCommentThreads: CommentThread[] = []; 75 | deserializednoteList.forEach((thread) => { 76 | deserializedCommentThreads.push(this.deserializeThread(thread)); 77 | }); 78 | return deserializedCommentThreads; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/breadcrumbs/export.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as vscode from 'vscode'; 4 | import { Trail } from '../models/breadcrumb'; 5 | import { formatRangeLabel } from './format'; 6 | import { fullPathToRelative } from '../utils'; 7 | 8 | const escapeCodeBlock = (value: string) => value.replace(/```/g, '\`\`\`'); 9 | 10 | const headline = (level: number, text: string) => `${'#'.repeat(level)} ${text}`; 11 | 12 | const formatDate = (value: string | undefined) => 13 | value ? new Date(value).toLocaleString() : undefined; 14 | 15 | const buildSummary = (trail: Trail) => { 16 | const files = new Set(trail.crumbs.map((crumb) => fullPathToRelative(crumb.uri.fsPath))); 17 | const first = formatDate(trail.crumbs[0]?.createdAt); 18 | const last = formatDate(trail.crumbs[trail.crumbs.length - 1]?.createdAt); 19 | 20 | const lines: string[] = []; 21 | lines.push(headline(2, 'Summary')); 22 | lines.push(''); 23 | lines.push(`- **Total crumbs:** ${trail.crumbs.length}`); 24 | lines.push(`- **Source Files Involved:** ${files.size}`); 25 | lines.push(`- **Generated:** ${formatDate(new Date().toISOString())}`); 26 | lines.push(''); 27 | return lines.join('\n'); 28 | }; 29 | 30 | const buildCrumbSection = (trail: Trail) => { 31 | const lines: string[] = []; 32 | lines.push(headline(2, 'Trail')); 33 | lines.push(''); 34 | 35 | trail.crumbs.forEach((crumb, index) => { 36 | const filePath = fullPathToRelative(crumb.uri.fsPath); 37 | const rangeLabel = formatRangeLabel(crumb.range); 38 | const createdAt = formatDate(crumb.createdAt) ?? 'n/a'; 39 | lines.push(headline(3, `${index + 1}. ${filePath}:${rangeLabel}`)); 40 | lines.push(''); 41 | lines.push(`- **Captured:** ${createdAt}`); 42 | if (crumb.note) { 43 | lines.push(`- **Note:** ${crumb.note}`); 44 | } 45 | lines.push(''); 46 | lines.push('```'); 47 | lines.push(escapeCodeBlock(crumb.snippet)); 48 | lines.push('```'); 49 | lines.push(''); 50 | }); 51 | 52 | return lines.join('\n'); 53 | }; 54 | 55 | const generateTrailMarkdown = (trail: Trail) => { 56 | const lines: string[] = []; 57 | lines.push(headline(1, `Breadcrumb Trail – ${trail.name}`)); 58 | lines.push(''); 59 | if (trail.description) { 60 | lines.push(trail.description); 61 | lines.push(''); 62 | } 63 | lines.push(buildSummary(trail)); 64 | lines.push(buildCrumbSection(trail)); 65 | return lines.join('\n'); 66 | }; 67 | 68 | export const exportTrailToMarkdown = async (trail: Trail, uri?: vscode.Uri) => { 69 | if (!trail.crumbs.length) { 70 | vscode.window.showInformationMessage('[Breadcrumbs] Cannot export an empty trail.'); 71 | return undefined; 72 | } 73 | 74 | const markdown = generateTrailMarkdown(trail); 75 | const buffer = Buffer.from(markdown, 'utf8'); 76 | 77 | if (!uri) { 78 | const fileNameSafe = trail.name.replace(/[^a-z0-9\-_]+/gi, '-').replace(/-+/g, '-'); 79 | const defaultUri = vscode.workspace.workspaceFolders?.length 80 | ? vscode.Uri.joinPath( 81 | vscode.workspace.workspaceFolders[0].uri, 82 | `${fileNameSafe || 'breadcrumb-trail'}-Breadcrumb.md`, 83 | ) 84 | : undefined; 85 | 86 | uri = await vscode.window.showSaveDialog({ 87 | filters: { Markdown: ['md', 'markdown'] }, 88 | defaultUri, 89 | saveLabel: 'Export Breadcrumb Trail', 90 | }); 91 | if (!uri) { 92 | return undefined; 93 | } 94 | } 95 | 96 | await vscode.workspace.fs.writeFile(uri, buffer); 97 | 98 | const selection = await vscode.window.showInformationMessage( 99 | `Breadcrumb trail exported to ${uri.fsPath}.`, 100 | 'Open export', 101 | ); 102 | 103 | if (selection === 'Open export') { 104 | const document = await vscode.workspace.openTextDocument(uri); 105 | await vscode.window.showTextDocument(document, { preview: false }); 106 | } 107 | 108 | return uri; 109 | }; 110 | -------------------------------------------------------------------------------- /src/persistence/local-db/breadcrumbs.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as fs from 'fs'; 4 | import * as vscode from 'vscode'; 5 | import { BreadcrumbStore } from '../../breadcrumbs/store'; 6 | import { BreadcrumbState, Crumb, Trail } from '../../models/breadcrumb'; 7 | import { fullPathToRelative, getBreadcrumbsDbFilePath, relativePathToFull } from '../../utils'; 8 | 9 | interface PersistedRange { 10 | startLine: number; 11 | startCharacter: number; 12 | endLine: number; 13 | endCharacter: number; 14 | } 15 | 16 | interface PersistedCrumb { 17 | id: string; 18 | trailId: string; 19 | uri: string; 20 | range: PersistedRange; 21 | snippet: string; 22 | note?: string; 23 | createdAt: string; 24 | } 25 | 26 | interface PersistedTrail { 27 | id: string; 28 | name: string; 29 | description?: string; 30 | createdAt: string; 31 | updatedAt: string; 32 | crumbs: PersistedCrumb[]; 33 | } 34 | 35 | interface PersistedState { 36 | activeTrailId?: string; 37 | trails: PersistedTrail[]; 38 | } 39 | 40 | const persistenceFile = getBreadcrumbsDbFilePath(); 41 | 42 | const serializeRange = (range: vscode.Range): PersistedRange => ({ 43 | startLine: range.start.line, 44 | startCharacter: range.start.character, 45 | endLine: range.end.line, 46 | endCharacter: range.end.character, 47 | }); 48 | 49 | const deserializeRange = (range: PersistedRange): vscode.Range => 50 | new vscode.Range(range.startLine, range.startCharacter, range.endLine, range.endCharacter); 51 | 52 | const serializeCrumb = (crumb: Crumb): PersistedCrumb => ({ 53 | id: crumb.id, 54 | trailId: crumb.trailId, 55 | uri: fullPathToRelative(crumb.uri.fsPath), 56 | range: serializeRange(crumb.range), 57 | snippet: crumb.snippet, 58 | note: crumb.note, 59 | createdAt: crumb.createdAt, 60 | }); 61 | 62 | const deserializeCrumb = (crumb: PersistedCrumb): Crumb => ({ 63 | id: crumb.id, 64 | trailId: crumb.trailId, 65 | uri: vscode.Uri.file(relativePathToFull(crumb.uri)), 66 | range: deserializeRange(crumb.range), 67 | snippet: crumb.snippet, 68 | note: crumb.note, 69 | createdAt: crumb.createdAt, 70 | }); 71 | 72 | const serializeTrail = (trail: Trail): PersistedTrail => ({ 73 | id: trail.id, 74 | name: trail.name, 75 | description: trail.description, 76 | createdAt: trail.createdAt, 77 | updatedAt: trail.updatedAt, 78 | crumbs: trail.crumbs.map((crumb) => serializeCrumb(crumb)), 79 | }); 80 | 81 | const deserializeTrail = (trail: PersistedTrail): Trail => ({ 82 | id: trail.id, 83 | name: trail.name, 84 | description: trail.description, 85 | createdAt: trail.createdAt, 86 | updatedAt: trail.updatedAt, 87 | crumbs: trail.crumbs.map((crumb) => deserializeCrumb(crumb)), 88 | }); 89 | 90 | export const saveBreadcrumbsToFile = (store: BreadcrumbStore) => { 91 | const state = store.getState(); 92 | if (!fs.existsSync(persistenceFile) && !state.trails.length) { 93 | return; 94 | } 95 | const persistedState: PersistedState = { 96 | activeTrailId: state.activeTrailId, 97 | trails: state.trails.map((trail) => serializeTrail(trail)), 98 | }; 99 | fs.writeFileSync(persistenceFile, JSON.stringify(persistedState, null, 2)); 100 | }; 101 | 102 | export const loadBreadcrumbsFromFile = (): BreadcrumbState => { 103 | if (!fs.existsSync(persistenceFile)) { 104 | return { activeTrailId: undefined, trails: [] }; 105 | } 106 | 107 | try { 108 | const jsonFile = fs.readFileSync(persistenceFile).toString(); 109 | const persistedState = JSON.parse(jsonFile) as PersistedState; 110 | return { 111 | activeTrailId: persistedState.activeTrailId, 112 | trails: persistedState.trails.map((trail) => deserializeTrail(trail)), 113 | }; 114 | } catch (error) { 115 | const message = error instanceof Error ? error.message : `${error}`; 116 | vscode.window.showErrorMessage( 117 | `[Breadcrumbs] Failed to load breadcrumbs from file: ${message}`, 118 | ); 119 | return { activeTrailId: undefined, trails: [] }; 120 | } 121 | }; 122 | -------------------------------------------------------------------------------- /src/webviews/assets/breadcrumbs.css: -------------------------------------------------------------------------------- 1 | .breadcrumbs-header { 2 | display: flex; 3 | align-items: center; 4 | justify-content: space-between; 5 | gap: 1rem; 6 | margin-bottom: 1rem; 7 | } 8 | 9 | .breadcrumbs-title { 10 | margin: 0; 11 | font-size: 1.2rem; 12 | } 13 | 14 | .breadcrumbs-subtitle { 15 | margin: 0.10rem 0 0; 16 | color: var(--vscode-descriptionForeground); 17 | font-size: 0.85rem; 18 | } 19 | 20 | .breadcrumbs-actions { 21 | display: flex; 22 | gap: 0.5rem; 23 | } 24 | 25 | .breadcrumbs-button { 26 | background: var(--vscode-button-secondaryBackground); 27 | border: 1px solid var(--vscode-button-border, transparent); 28 | color: var(--vscode-button-secondaryForeground); 29 | border-radius: 4px; 30 | padding: 0.3rem 0.8rem; 31 | cursor: pointer; 32 | white-space: nowrap; 33 | } 34 | 35 | .breadcrumbs-button:hover { 36 | background: var(--vscode-button-secondaryHoverBackground); 37 | } 38 | 39 | .breadcrumbs-select-label { 40 | display: block; 41 | margin-bottom: 0.25rem; 42 | font-weight: 600; 43 | } 44 | 45 | .breadcrumbs-select { 46 | width: 100%; 47 | margin-bottom: 1rem; 48 | padding: 0.4rem; 49 | border-radius: 4px; 50 | border: 1px solid var(--vscode-settings-dropdownBorder); 51 | background: var(--vscode-settings-dropdownBackground); 52 | color: var(--vscode-settings-dropdownForeground); 53 | } 54 | 55 | .breadcrumbs-content { 56 | display: flex; 57 | flex-direction: column; 58 | gap: 1rem; 59 | } 60 | 61 | .breadcrumbs-empty { 62 | color: var(--vscode-descriptionForeground); 63 | font-style: italic; 64 | } 65 | 66 | .breadcrumbs-summary h3 { 67 | margin: 0; 68 | } 69 | 70 | .breadcrumbs-summary p { 71 | margin: 0.3rem 0 0; 72 | color: var(--vscode-descriptionForeground); 73 | } 74 | 75 | .crumb-list { 76 | display: flex; 77 | flex-direction: column; 78 | gap: 0.9rem; 79 | } 80 | 81 | .crumb-item { 82 | border: 1px solid var(--vscode-editorWidget-border); 83 | border-radius: 8px; 84 | background: var(--vscode-editorWidget-background); 85 | transition: border-color 0.2s ease, box-shadow 0.2s ease; 86 | } 87 | 88 | .crumb-item[open] { 89 | border-color: var(--vscode-focusBorder); 90 | box-shadow: 0 0 0 1px var(--vscode-focusBorder); 91 | } 92 | 93 | .crumb-item summary { 94 | list-style: none; 95 | display: flex; 96 | align-items: center; 97 | gap: 0.75rem; 98 | padding: 0.85rem 1rem; 99 | cursor: pointer; 100 | } 101 | 102 | .crumb-item summary::-webkit-details-marker { 103 | display: none; 104 | } 105 | 106 | .crumb-summary__meta { 107 | display: flex; 108 | align-items: center; 109 | gap: 0.75rem; 110 | flex: 1; 111 | min-width: 0; 112 | } 113 | 114 | .crumb-step { 115 | font-size: 0.75rem; 116 | letter-spacing: 0.06em; 117 | text-transform: uppercase; 118 | background: var(--vscode-editorWidget-background); 119 | border: 1px solid var(--vscode-editorWidget-border); 120 | color: var(--vscode-editor-foreground); 121 | border-radius: 5px; 122 | padding: 0.15rem 0.5rem; 123 | font-weight: 600; 124 | } 125 | 126 | .crumb-title { 127 | font-weight: 600; 128 | color: var(--vscode-editor-foreground); 129 | overflow: hidden; 130 | text-overflow: ellipsis; 131 | white-space: nowrap; 132 | } 133 | 134 | .crumb-preview { 135 | color: var(--vscode-descriptionForeground); 136 | font-size: 0.85rem; 137 | flex: 1; 138 | overflow: hidden; 139 | text-overflow: ellipsis; 140 | white-space: nowrap; 141 | } 142 | 143 | .crumb-chevron { 144 | color: var(--vscode-descriptionForeground); 145 | font-size: 0.85rem; 146 | } 147 | 148 | .crumb-body { 149 | padding: 0 1rem 1rem; 150 | display: flex; 151 | flex-direction: column; 152 | gap: 0.75rem; 153 | border-top: 1px solid var(--vscode-editorWidget-border); 154 | } 155 | 156 | .crumb-meta { 157 | margin: 0.5rem 0 0; 158 | font-size: 0.75rem; 159 | color: var(--vscode-descriptionForeground); 160 | } 161 | 162 | .crumb-note { 163 | margin: 0; 164 | padding-left: 0.75rem; 165 | border-left: 3px solid var(--vscode-textPreformat-foreground); 166 | color: var(--vscode-descriptionForeground); 167 | font-style: italic; 168 | } 169 | 170 | .crumb-snippet { 171 | margin: 0; 172 | background: var(--vscode-editor-background); 173 | border: 1px solid var(--vscode-editorWidget-border); 174 | border-radius: 4px; 175 | padding: 0.6rem; 176 | white-space: pre-wrap; 177 | word-break: break-word; 178 | font-family: var(--vscode-editor-font-family, monospace); 179 | font-size: 0.85rem; 180 | cursor: pointer; 181 | transition: border-color 0.2s ease, box-shadow 0.2s ease; 182 | } 183 | 184 | .crumb-snippet:hover { 185 | border-color: var(--vscode-focusBorder); 186 | box-shadow: 0 0 0 1px var(--vscode-focusBorder); 187 | } -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { NoteStatus } from './models/noteStatus'; 3 | import { NoteComment } from './models/noteComment'; 4 | import { getReactionGroup } from './reactions/resource'; 5 | import { RemoteDb } from './persistence/remote-db'; 6 | import { v4 as uuidv4 } from 'uuid'; 7 | import { Deserializer } from './persistence/serialization/deserializer'; 8 | import { saveNotesToFile } from './persistence/local-db'; 9 | import { getSetting } from './utils'; 10 | 11 | export const saveNoteComment = ( 12 | thread: vscode.CommentThread, 13 | text: string, 14 | firstComment: boolean, 15 | noteMap: Map, 16 | author?: string, 17 | remoteDb?: RemoteDb, 18 | ) => { 19 | const newComment = new NoteComment( 20 | text, 21 | vscode.CommentMode.Preview, 22 | { name: author ? author : getSetting('authorName') }, 23 | thread, 24 | getReactionGroup().map((reaction) => ({ 25 | iconPath: reaction.icon, 26 | label: reaction.label, 27 | count: 0, 28 | authorHasReacted: false, 29 | })), 30 | thread.comments.length ? 'canDelete' : undefined, 31 | ); 32 | thread.comments = [...thread.comments, newComment]; 33 | if (firstComment) { 34 | thread.contextValue = uuidv4(); 35 | noteMap.set(thread.contextValue, thread); 36 | } 37 | saveNotesToFile(noteMap); 38 | if (remoteDb) { 39 | remoteDb.pushNoteComment(thread, firstComment); 40 | } 41 | }; 42 | 43 | export const setNoteStatus = ( 44 | thread: vscode.CommentThread, 45 | status: NoteStatus, 46 | noteMap: Map, 47 | author?: string, 48 | remoteDb?: RemoteDb, 49 | replyText?: string, 50 | ) => { 51 | const comment: vscode.Comment | any = thread.comments[0]; 52 | 53 | let originalText = comment.body.toString(); 54 | 55 | // Clean up any existing status badges 56 | const statusValuesPattern = Object.values(NoteStatus).join('|'); 57 | const statusRegex = new RegExp(`^\\[(${statusValuesPattern})\\] `, 'g'); 58 | originalText = originalText.replace(statusRegex, ''); 59 | 60 | // Update the comment 61 | comment.body = `[${status}] ${originalText}`; 62 | comment.savedBody = originalText; 63 | 64 | // Add note comment about status change 65 | const statusMessage = replyText ? 66 | `Status changed to ${status}.\n\n${replyText}` : 67 | `Status changed to ${status}.`; 68 | 69 | saveNoteComment( 70 | thread, 71 | statusMessage, 72 | false, 73 | noteMap, 74 | author ? author : '', 75 | remoteDb, 76 | ); 77 | }; 78 | 79 | export const mergeThread = (local: vscode.CommentThread, remote: any): boolean => { 80 | // add comments of new thread to current thread if not exist 81 | // TODO: replace with structuredClone() 82 | const mergedComments: vscode.Comment[] = []; 83 | let merged = false; 84 | local.comments.forEach((comment) => { 85 | mergedComments.push(comment); 86 | }); 87 | 88 | remote.comments.forEach((comment: any) => { 89 | comment = Deserializer.deserializeComment(comment, undefined); 90 | if ( 91 | !local.comments.find( 92 | (currentComment) => 93 | Number(currentComment.timestamp) == Number(comment.timestamp), 94 | ) 95 | ) { 96 | comment.parent = local; 97 | mergedComments.push(comment); 98 | merged = true; 99 | } 100 | }); 101 | 102 | // sort all comments and assign unique comments to current thread 103 | mergedComments.sort( 104 | (a: vscode.Comment, b: vscode.Comment) => 105 | (a.timestamp ? Number(a.timestamp) : 0) - (b.timestamp ? Number(b.timestamp) : 0), 106 | ); 107 | 108 | // mark all merged comments as deletable, except for the first one 109 | mergedComments.slice(1).forEach((comment) => (comment.contextValue = 'canDelete')); 110 | 111 | // assigned unique and sorted comments to current thread 112 | local.comments = mergedComments; 113 | return merged; 114 | }; 115 | 116 | export const syncNoteMapWithRemote = ( 117 | noteMap: Map, 118 | remoteSerializedThreads: any, 119 | remoteDb: RemoteDb | undefined, 120 | ) => { 121 | // pull remote threads 122 | remoteSerializedThreads.forEach((remoteSerializedThread: any) => { 123 | const threadId = remoteSerializedThread.id; 124 | // if remote thread doesn't exist in local map, add it to local 125 | if (!noteMap.has(threadId)) { 126 | const remoteThread: vscode.CommentThread = 127 | Deserializer.deserializeThread(remoteSerializedThread); 128 | noteMap.set(threadId, remoteThread); 129 | return; 130 | } 131 | 132 | // get local thread 133 | const localThread = noteMap.get(threadId); 134 | if (!localThread) { 135 | return; 136 | } 137 | 138 | // if new comments were merged, push to remote 139 | if (mergeThread(localThread, remoteSerializedThread)) { 140 | remoteDb && remoteDb.pushNoteComment(localThread, false); 141 | } 142 | }); 143 | 144 | // push local only threads to remote 145 | noteMap.forEach((localThread, id) => { 146 | if ( 147 | !remoteSerializedThreads.find((remoteSerializedThread: any) => { 148 | return remoteSerializedThread.contextValue == id; 149 | }) 150 | ) { 151 | remoteDb && remoteDb.pushNoteComment(localThread, true); 152 | } 153 | }); 154 | 155 | saveNotesToFile(noteMap); 156 | }; -------------------------------------------------------------------------------- /src/breadcrumbs/store.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as vscode from 'vscode'; 4 | import { v4 as uuidv4 } from 'uuid'; 5 | import { BreadcrumbState, Crumb, Trail } from '../models/breadcrumb'; 6 | 7 | export interface CreateTrailOptions { 8 | description?: string; 9 | setActive?: boolean; 10 | } 11 | 12 | export interface CreateCrumbOptions { 13 | note?: string; 14 | } 15 | 16 | const cloneRange = (range: vscode.Range) => 17 | new vscode.Range(range.start.line, range.start.character, range.end.line, range.end.character); 18 | 19 | const cloneUri = (uri: vscode.Uri) => vscode.Uri.parse(uri.toString()); 20 | 21 | const cloneCrumb = (crumb: Crumb): Crumb => ({ 22 | ...crumb, 23 | uri: cloneUri(crumb.uri), 24 | range: cloneRange(crumb.range), 25 | }); 26 | 27 | const cloneTrail = (trail: Trail): Trail => ({ 28 | ...trail, 29 | crumbs: trail.crumbs.map((crumb) => cloneCrumb(crumb)), 30 | }); 31 | 32 | export class BreadcrumbStore { 33 | private trails = new Map(); 34 | 35 | private activeTrailId: string | undefined; 36 | 37 | private readonly _onDidChange = new vscode.EventEmitter(); 38 | 39 | public readonly onDidChange: vscode.Event = this._onDidChange.event; 40 | 41 | public getState(): BreadcrumbState { 42 | return { 43 | activeTrailId: this.activeTrailId, 44 | trails: [...this.trails.values()].map((trail) => cloneTrail(trail)), 45 | }; 46 | } 47 | 48 | public replaceState(state: BreadcrumbState) { 49 | this.trails.clear(); 50 | state.trails.forEach((trail) => { 51 | const cloned = cloneTrail(trail); 52 | this.trails.set(cloned.id, cloned); 53 | }); 54 | this.activeTrailId = state.activeTrailId; 55 | this._onDidChange.fire(); 56 | } 57 | 58 | public getTrails(): Trail[] { 59 | return [...this.trails.values()] 60 | .map((trail) => cloneTrail(trail)) 61 | .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); 62 | } 63 | 64 | public getTrail(trailId: string): Trail | undefined { 65 | const trail = this.trails.get(trailId); 66 | return trail ? cloneTrail(trail) : undefined; 67 | } 68 | 69 | public getActiveTrail(): Trail | undefined { 70 | if (!this.activeTrailId) { 71 | return undefined; 72 | } 73 | return this.getTrail(this.activeTrailId); 74 | } 75 | 76 | public setActiveTrail(trailId: string | undefined) { 77 | this.activeTrailId = trailId; 78 | this._onDidChange.fire(); 79 | } 80 | 81 | public createTrail(name: string, options: CreateTrailOptions = {}): Trail { 82 | const id = uuidv4(); 83 | const timestamp = new Date().toISOString(); 84 | const trail: Trail = { 85 | id, 86 | name, 87 | description: options.description, 88 | createdAt: timestamp, 89 | updatedAt: timestamp, 90 | crumbs: [], 91 | }; 92 | this.trails.set(id, trail); 93 | if (options.setActive ?? true) { 94 | this.activeTrailId = id; 95 | } 96 | this._onDidChange.fire(); 97 | return cloneTrail(trail); 98 | } 99 | 100 | public renameTrail(trailId: string, name: string, description?: string) { 101 | const trail = this.trails.get(trailId); 102 | if (!trail) { 103 | return; 104 | } 105 | trail.name = name; 106 | trail.description = description; 107 | trail.updatedAt = new Date().toISOString(); 108 | this._onDidChange.fire(); 109 | } 110 | 111 | public deleteTrail(trailId: string) { 112 | if (!this.trails.has(trailId)) { 113 | return; 114 | } 115 | this.trails.delete(trailId); 116 | if (this.activeTrailId === trailId) { 117 | this.activeTrailId = this.trails.size ? [...this.trails.keys()][0] : undefined; 118 | } 119 | this._onDidChange.fire(); 120 | } 121 | 122 | public addCrumb( 123 | trailId: string, 124 | uri: vscode.Uri, 125 | range: vscode.Range, 126 | snippet: string, 127 | options: CreateCrumbOptions = {}, 128 | ): Crumb | undefined { 129 | const trail = this.trails.get(trailId); 130 | if (!trail) { 131 | return undefined; 132 | } 133 | const crumb: Crumb = { 134 | id: uuidv4(), 135 | trailId, 136 | uri, 137 | range, 138 | snippet, 139 | note: options.note, 140 | createdAt: new Date().toISOString(), 141 | }; 142 | trail.crumbs = [...trail.crumbs, crumb]; 143 | trail.updatedAt = new Date().toISOString(); 144 | this._onDidChange.fire(); 145 | return cloneCrumb(crumb); 146 | } 147 | 148 | public updateCrumbNote(trailId: string, crumbId: string, note: string | undefined) { 149 | const trail = this.trails.get(trailId); 150 | if (!trail) { 151 | return; 152 | } 153 | const index = trail.crumbs.findIndex((crumb) => crumb.id === crumbId); 154 | if (index === -1) { 155 | return; 156 | } 157 | trail.crumbs[index] = { 158 | ...trail.crumbs[index], 159 | note, 160 | }; 161 | trail.updatedAt = new Date().toISOString(); 162 | this._onDidChange.fire(); 163 | } 164 | 165 | public removeCrumb(trailId: string, crumbId: string) { 166 | const trail = this.trails.get(trailId); 167 | if (!trail) { 168 | return; 169 | } 170 | const next = trail.crumbs.filter((crumb) => crumb.id !== crumbId); 171 | if (next.length === trail.crumbs.length) { 172 | return; 173 | } 174 | trail.crumbs = next; 175 | trail.updatedAt = new Date().toISOString(); 176 | this._onDidChange.fire(); 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/persistence/remote-db/index.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as rethinkdb from 'rethinkdb'; 3 | import { Serializer } from '../serialization/serializer'; 4 | import { Deserializer } from '../serialization/deserializer'; 5 | import { readFileSync } from 'fs'; 6 | import { saveNotesToFile } from '../local-db'; 7 | 8 | export class RemoteDb { 9 | private host: string; 10 | private port: number; 11 | private username: string; 12 | private password: string; 13 | private database: string; 14 | private table: string; 15 | private ssl: string; 16 | private noteMap: Map; 17 | private connection: any; 18 | 19 | public constructor( 20 | host: string, 21 | port: number, 22 | username: string, 23 | password: string, 24 | database: string, 25 | table: string, 26 | ssl: string, 27 | noteMap: Map, 28 | ) { 29 | this.host = host; 30 | this.port = port; 31 | this.username = username; 32 | this.password = password; 33 | this.database = database; 34 | this.table = table; 35 | this.ssl = ssl; 36 | this.noteMap = noteMap; 37 | 38 | this.connect(); 39 | } 40 | 41 | public connect() { 42 | rethinkdb.connect( 43 | { 44 | host: this.host, 45 | port: this.port, 46 | db: this.database, 47 | user: this.username, 48 | password: this.password, 49 | ssl: 50 | this.ssl !== '' 51 | ? { 52 | ca: readFileSync(this.ssl).toString().trim(), 53 | } 54 | : undefined, 55 | }, 56 | (err: Error, conn: any) => { 57 | if (err) { 58 | vscode.window.showErrorMessage( 59 | 'An error has occurred while connecting to the remote DB: ' + err, 60 | ); 61 | return; 62 | } 63 | vscode.window.showInformationMessage( 64 | 'Connection to remote DB was established successfully.', 65 | ); 66 | this.connection = conn; 67 | this.createDatabase(); 68 | }, 69 | ); 70 | } 71 | 72 | public createDatabase() { 73 | rethinkdb 74 | .dbCreate(this.database) 75 | .run(this.connection, (err: Error, cursor: any) => { 76 | if (err && err.name !== 'ReqlOpFailedError') { 77 | vscode.window.showErrorMessage( 78 | 'An error has occurred while creating DB: ' + err, 79 | ); 80 | return; 81 | } 82 | 83 | if (cursor && cursor.dbs_created) { 84 | vscode.window.showInformationMessage('DB created successfully.'); 85 | } 86 | 87 | this.createTable(); 88 | }); 89 | } 90 | 91 | public createTable() { 92 | rethinkdb 93 | .db(this.database) 94 | .tableCreate(this.table) 95 | .run(this.connection, (err: Error, cursor: any) => { 96 | if (err && err.name !== 'ReqlOpFailedError') { 97 | vscode.window.showErrorMessage( 98 | 'An error has occurred while creating table: ' + err, 99 | ); 100 | return; 101 | } 102 | 103 | if (cursor && cursor.tables_created) { 104 | vscode.window.showInformationMessage('Table created successfully.'); 105 | } 106 | 107 | this.subscribe(); 108 | }); 109 | } 110 | 111 | public subscribe() { 112 | rethinkdb 113 | .db(this.database) 114 | .table(this.table) 115 | .changes() 116 | .run(this.connection, (err: Error, cursor: any) => { 117 | if (err) { 118 | vscode.window.showErrorMessage( 119 | 'An error has occurred while subscribing to the remote DB:' + err, 120 | ); 121 | return; 122 | } 123 | cursor.each((err: Error, row: any) => { 124 | if (err) { 125 | vscode.window.showErrorMessage( 126 | 'An error has occurred while fetching from remote DB:' + err, 127 | ); 128 | return; 129 | } 130 | this.noteMap.get(row.new_val.id)?.dispose(); 131 | const newThread: vscode.CommentThread = Deserializer.deserializeThread( 132 | row.new_val, 133 | ); 134 | this.noteMap.set( 135 | newThread.contextValue ? newThread.contextValue : '', 136 | newThread, 137 | ); 138 | saveNotesToFile(this.noteMap); 139 | vscode.window.showInformationMessage('Note received from remote DB.'); 140 | }); 141 | }); 142 | } 143 | 144 | public async retrieveAll(): Promise { 145 | return rethinkdb 146 | .db(this.database) 147 | .table(this.table) 148 | .run(this.connection) 149 | .then((cursor) => { 150 | return cursor.toArray(); 151 | }) 152 | .then((output) => { 153 | const remoteSerializedThreads: any = []; 154 | output.forEach((remoteSerializedThread) => { 155 | remoteSerializedThreads.push(remoteSerializedThread); 156 | }); 157 | return remoteSerializedThreads; 158 | }); 159 | } 160 | 161 | public pushNoteComment(note: vscode.CommentThread, firstComment: boolean) { 162 | const st = JSON.parse(JSON.stringify(Serializer.serializeThread(note))); 163 | if (firstComment) { 164 | rethinkdb 165 | .db(this.database) 166 | .table(this.table) 167 | .insert(st) 168 | .run(this.connection, function (err, result) { 169 | if (err) { 170 | vscode.window.showErrorMessage( 171 | 'An error has occurred while inserting to remote DB:' + err, 172 | ); 173 | return; 174 | } 175 | if (result.inserted) { 176 | vscode.window.showInformationMessage( 177 | 'Note was inserted successfully in remote DB.', 178 | ); 179 | } 180 | }); 181 | } else { 182 | rethinkdb 183 | .db(this.database) 184 | .table(this.table) 185 | .get(note.contextValue ? note.contextValue : '') 186 | .update(st) 187 | .run(this.connection, function (err, result) { 188 | if (err) { 189 | vscode.window.showErrorMessage( 190 | 'An error has occurred while inserting to remote DB:' + err, 191 | ); 192 | return; 193 | } 194 | if (result.inserted) { 195 | vscode.window.showInformationMessage( 196 | 'Note was inserted successfully in remote DB.', 197 | ); 198 | } 199 | }); 200 | } 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/webviews/import-tool-results/importToolResultsWebview.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as vscode from 'vscode'; 4 | import { commentController } from '../../controllers/comments'; 5 | import { BanditParser } from '../../parsers/bandit'; 6 | import { BrakemanParser } from '../../parsers/brakeman'; 7 | import { CheckovParser } from '../../parsers/checkov'; 8 | import { GosecParser } from '../../parsers/gosec'; 9 | import { SemgrepParser } from '../../parsers/semgrep'; 10 | import { ToolFinding } from '../../models/toolFinding'; 11 | import { saveNoteComment } from '../../helpers'; 12 | import { RemoteDb } from '../../persistence/remote-db'; 13 | 14 | export class ImportToolResultsWebview implements vscode.WebviewViewProvider { 15 | public static readonly viewType = 'import-tool-results-view'; 16 | 17 | private _view?: vscode.WebviewView; 18 | private noteMap: Map; 19 | private remoteDb: RemoteDb | undefined; 20 | 21 | constructor( 22 | private readonly _extensionUri: vscode.Uri, 23 | noteMap: Map, 24 | remoteDb: RemoteDb | undefined, 25 | ) { 26 | this.noteMap = noteMap; 27 | this.remoteDb = remoteDb ? remoteDb : undefined; 28 | } 29 | 30 | public resolveWebviewView( 31 | webviewView: vscode.WebviewView, 32 | _context: vscode.WebviewViewResolveContext, 33 | _token: vscode.CancellationToken, 34 | ) { 35 | this._view = webviewView; 36 | 37 | webviewView.webview.options = { 38 | // Allow scripts in the webview 39 | enableScripts: true, 40 | localResourceRoots: [this._extensionUri], 41 | }; 42 | 43 | webviewView.webview.html = this._getHtmlForWebview(webviewView.webview); 44 | 45 | webviewView.webview.onDidReceiveMessage((data) => { 46 | switch (data.type) { 47 | case 'processToolFile': { 48 | processToolFile(data.toolName, data.fileContent, this.noteMap, this.remoteDb); 49 | } 50 | } 51 | }); 52 | } 53 | 54 | private _getHtmlForWebview(webview: vscode.Webview) { 55 | const scriptUri = webview.asWebviewUri( 56 | vscode.Uri.joinPath( 57 | this._extensionUri, 58 | 'src', 59 | 'webviews', 60 | 'assets', 61 | 'importToolResults.js', 62 | ), 63 | ); 64 | const styleResetUri = webview.asWebviewUri( 65 | vscode.Uri.joinPath(this._extensionUri, 'src', 'webviews', 'assets', 'reset.css'), 66 | ); 67 | const styleVSCodeUri = webview.asWebviewUri( 68 | vscode.Uri.joinPath( 69 | this._extensionUri, 70 | 'src', 71 | 'webviews', 72 | 'assets', 73 | 'vscode.css', 74 | ), 75 | ); 76 | const styleMainUri = webview.asWebviewUri( 77 | vscode.Uri.joinPath(this._extensionUri, 'src', 'webviews', 'assets', 'main.css'), 78 | ); 79 | 80 | return ` 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 |

Select tool:

90 |

91 | 98 |

99 |
100 | 101 |

Select file:

102 |

103 | 104 |

105 |
106 | 107 |

108 | 109 |

110 | 111 | 112 | 113 | `; 114 | } 115 | } 116 | 117 | function processToolFile( 118 | toolName: string, 119 | fileContent: string, 120 | noteMap: Map, 121 | remoteDb: RemoteDb | undefined, 122 | ) { 123 | let toolFindings: ToolFinding[] = []; 124 | 125 | // parse tool findings 126 | switch (toolName) { 127 | case 'bandit': { 128 | toolFindings = BanditParser.parse(fileContent); 129 | break; 130 | } 131 | case 'brakeman': { 132 | toolFindings = BrakemanParser.parse(fileContent); 133 | break; 134 | } 135 | case 'checkov': { 136 | toolFindings = CheckovParser.parse(fileContent); 137 | break; 138 | } 139 | case 'gosec': { 140 | toolFindings = GosecParser.parse(fileContent); 141 | break; 142 | } 143 | case 'semgrep': { 144 | toolFindings = SemgrepParser.parse(fileContent); 145 | break; 146 | } 147 | } 148 | 149 | if (!toolFindings.length) { 150 | vscode.window.showErrorMessage( 151 | '[Import] An error has ocurred while parsing the file.', 152 | ); 153 | return; 154 | } 155 | 156 | if (noteMap.size && identifyPotentialDuplicates(toolName, noteMap)) { 157 | vscode.window 158 | .showWarningMessage( 159 | `[Import] Potential duplicates. Current comments already include findings from ${toolName}. Do you want to import findings anyway?`, 160 | 'Yes', 161 | 'No', 162 | ) 163 | .then((answer) => { 164 | if (answer === 'Yes') { 165 | saveToolFindings(toolFindings, noteMap, toolName, remoteDb); 166 | } 167 | }); 168 | } else { 169 | saveToolFindings(toolFindings, noteMap, toolName, remoteDb); 170 | } 171 | } 172 | 173 | function identifyPotentialDuplicates( 174 | toolName: string, 175 | noteMap: Map, 176 | ) { 177 | // return noteList.some((thread) => { 178 | // return thread.comments[0].author.name === toolName; 179 | // }); 180 | return false; 181 | } 182 | 183 | function saveToolFindings( 184 | toolFindings: ToolFinding[], 185 | noteMap: Map, 186 | toolName: string, 187 | remoteDb: RemoteDb | undefined, 188 | ) { 189 | // instantiate comments based on parsed tool findings 190 | toolFindings.forEach((toolFinding: ToolFinding) => { 191 | const newThread = commentController.createCommentThread( 192 | toolFinding.uri, 193 | toolFinding.range, 194 | [], 195 | ); 196 | saveNoteComment(newThread, toolFinding.text, true, noteMap, toolName, remoteDb); 197 | }); 198 | vscode.window.showInformationMessage( 199 | `[Import] ${toolFindings.length} findings were imported successfully.`, 200 | ); 201 | } 202 | -------------------------------------------------------------------------------- /src/webviews/assets/breadcrumbs.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | 3 | (function () { 4 | const vscode = acquireVsCodeApi(); 5 | 6 | const trailSelect = document.getElementById('trail-select'); 7 | const content = document.getElementById('breadcrumbs-content'); 8 | const createButton = document.querySelector('[data-action="create"]'); 9 | const addButton = document.querySelector('[data-action="add"]'); 10 | const exportButton = document.querySelector('[data-action="export"]'); 11 | 12 | let currentState; 13 | 14 | window.addEventListener('message', (event) => { 15 | const { type, payload } = event.data; 16 | if (type === 'state') { 17 | currentState = payload; 18 | renderState(payload); 19 | } 20 | }); 21 | 22 | trailSelect.addEventListener('change', (event) => { 23 | if (!currentState) { 24 | return; 25 | } 26 | const selectedTrailId = event.target.value; 27 | if (!selectedTrailId) { 28 | return; 29 | } 30 | if (selectedTrailId === currentState.activeTrailId) { 31 | return; 32 | } 33 | vscode.postMessage({ type: 'setActiveTrail', trailId: selectedTrailId }); 34 | }); 35 | 36 | createButton.addEventListener('click', () => { 37 | vscode.postMessage({ type: 'createTrail' }); 38 | }); 39 | 40 | if (addButton) { 41 | addButton.remove(); 42 | } 43 | 44 | exportButton.addEventListener('click', () => { 45 | vscode.postMessage({ type: 'exportTrail' }); 46 | }); 47 | 48 | function renderState(state) { 49 | populateTrailSelect(state); 50 | 51 | if (!state.trails.length) { 52 | renderEmpty('Create a trail to start visualising your breadcrumbs.'); 53 | return; 54 | } 55 | 56 | if (!state.activeTrail) { 57 | renderEmpty('Select a trail from the dropdown to view its breadcrumbs.'); 58 | return; 59 | } 60 | 61 | renderTrail(state.activeTrail); 62 | } 63 | 64 | function populateTrailSelect(state) { 65 | trailSelect.innerHTML = ''; 66 | 67 | const placeholderOption = document.createElement('option'); 68 | placeholderOption.value = ''; 69 | placeholderOption.textContent = state.trails.length ? 'Select a trail' : 'No trails yet'; 70 | placeholderOption.disabled = true; 71 | placeholderOption.hidden = true; 72 | trailSelect.appendChild(placeholderOption); 73 | 74 | state.trails.forEach((trail) => { 75 | const option = document.createElement('option'); 76 | option.value = trail.id; 77 | option.textContent = `${trail.name} (${trail.crumbCount})`; 78 | if (trail.id === state.activeTrailId) { 79 | option.selected = true; 80 | } 81 | trailSelect.appendChild(option); 82 | }); 83 | 84 | if (state.activeTrailId) { 85 | trailSelect.value = state.activeTrailId; 86 | } else if (state.activeTrail) { 87 | trailSelect.value = state.activeTrail.id; 88 | } else { 89 | trailSelect.value = ''; 90 | } 91 | } 92 | 93 | function renderEmpty(message) { 94 | content.innerHTML = ''; 95 | const empty = document.createElement('p'); 96 | empty.className = 'breadcrumbs-empty'; 97 | empty.textContent = message; 98 | content.appendChild(empty); 99 | } 100 | 101 | function renderTrail(trail) { 102 | content.innerHTML = ''; 103 | 104 | const summary = document.createElement('div'); 105 | summary.className = 'breadcrumbs-summary'; 106 | 107 | const title = document.createElement('h3'); 108 | title.textContent = trail.name; 109 | summary.appendChild(title); 110 | 111 | const meta = document.createElement('p'); 112 | const crumbLabel = trail.crumbs.length === 1 ? 'crumb' : 'crumbs'; 113 | const details = []; 114 | details.push(`${trail.crumbs.length} ${crumbLabel}`); 115 | if (trail.description) { 116 | details.push(trail.description); 117 | } 118 | meta.textContent = details.join(' · '); 119 | summary.appendChild(meta); 120 | 121 | content.appendChild(summary); 122 | 123 | if (!trail.crumbs.length) { 124 | renderEmpty('This trail does not have any crumbs yet. Add one to build your diagram.'); 125 | return; 126 | } 127 | 128 | const list = document.createElement('div'); 129 | list.className = 'crumb-list'; 130 | 131 | trail.crumbs.forEach((crumb, index) => { 132 | list.appendChild(renderCrumb(crumb, index, trail.id)); 133 | }); 134 | 135 | content.appendChild(list); 136 | } 137 | 138 | function renderCrumb(crumb, index, trailId) { 139 | const details = document.createElement('details'); 140 | details.className = 'crumb-item'; 141 | details.dataset.crumbId = crumb.id; 142 | details.dataset.trailId = trailId; 143 | if (index === 0) { 144 | details.open = true; 145 | } 146 | 147 | const summary = document.createElement('summary'); 148 | summary.className = 'crumb-summary'; 149 | 150 | const summaryMeta = document.createElement('div'); 151 | summaryMeta.className = 'crumb-summary__meta'; 152 | 153 | const step = document.createElement('span'); 154 | step.className = 'crumb-step'; 155 | step.textContent = `Step ${index + 1}`; 156 | summaryMeta.appendChild(step); 157 | 158 | const title = document.createElement('span'); 159 | title.className = 'crumb-title'; 160 | title.textContent = `${crumb.filePath}:${crumb.rangeLabel}`; 161 | summaryMeta.appendChild(title); 162 | 163 | summary.appendChild(summaryMeta); 164 | 165 | const preview = document.createElement('span'); 166 | preview.className = 'crumb-preview'; 167 | preview.textContent = crumb.note || crumb.snippetPreview || '(no preview)'; 168 | summary.appendChild(preview); 169 | 170 | const chevron = document.createElement('span'); 171 | chevron.className = 'crumb-chevron'; 172 | chevron.innerHTML = '▾'; 173 | summary.appendChild(chevron); 174 | 175 | details.appendChild(summary); 176 | 177 | const body = document.createElement('div'); 178 | body.className = 'crumb-body'; 179 | 180 | const meta = document.createElement('p'); 181 | meta.className = 'crumb-meta'; 182 | const created = new Date(crumb.createdAt).toLocaleString(); 183 | meta.textContent = `Captured ${created}`; 184 | body.appendChild(meta); 185 | 186 | if (crumb.note) { 187 | const note = document.createElement('p'); 188 | note.className = 'crumb-note'; 189 | note.textContent = crumb.note; 190 | body.appendChild(note); 191 | } 192 | 193 | const snippet = document.createElement('pre'); 194 | snippet.className = 'crumb-snippet'; 195 | snippet.textContent = crumb.snippet; 196 | snippet.addEventListener('click', (event) => { 197 | event.stopPropagation(); 198 | vscode.postMessage({ type: 'openCrumb', trailId, crumbId: crumb.id }); 199 | }); 200 | body.appendChild(snippet); 201 | 202 | details.addEventListener('toggle', () => { 203 | if (details.open) { 204 | chevron.innerHTML = '▾'; 205 | } else { 206 | chevron.innerHTML = '▸'; 207 | } 208 | }); 209 | 210 | chevron.innerHTML = details.open ? '▾' : '▸'; 211 | details.appendChild(body); 212 | 213 | return details; 214 | } 215 | 216 | vscode.postMessage({ type: 'ready' }); 217 | })(); 218 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | icon 4 | 5 | 6 | Security Notes 7 | 8 |

9 |

10 | A Visual Studio Code extension to aid code reviews from a security perspective. 11 |

12 | 13 | --- 14 | 15 | Security Notes allows the creation of notes within source files, which can be replied to, reacted to using emojis, and assigned statuses such as "TODO", "Vulnerable" and "Not Vulnerable". Also, it allows importing the output from SAST tools (such as semgrep, bandit and brakeman), into notes, making the processing of the findings much easier. 16 | 17 | Use the **Breadcrumbs** feature to track complex implementations accross different source files. This way you will be able to visualize how a feature works, and export it so you can share your analysis with others. 18 | 19 | Finally, collaborate with others by using a centralized database for notes that will be automatically synced in **real-time**! Create a note locally, and it will be automatically pushed to whoever is working with you on the project. 20 | 21 | ## Try it out! 22 | 23 | Download the extension directly from the Visual Studio [Marketplace](https://marketplace.visualstudio.com/items?itemName=refactor-security.security-notes) and you should be ready to go :) 24 | 25 | ### Alternative installation methods 26 | 27 | Please follow any of the alternatives below: 28 | 29 | - Download the [latest release](https://github.com/RefactorSecurity/vscode-security-notes/releases) file (with the `.vsix` extension) and install manually in VSCode via **Extensions** > **Install From VSIX** 30 | - Build the extension yourself 31 | - Clone the repo 32 | - Install VS Code Extension Manager via `npm install -g @vscode/vsce` 33 | - Create a `.vsix` package via `vsce package` 34 | 35 | ## Basic Usage 36 | 37 | Security Notes allows the creation of notes within source files, which can be replied to, reacted to using emojis, and assigned statuses such as "TODO", "Vulnerable" and "Not Vulnerable". 38 | 39 | ![Demo for basic usage](images/demo-basic-usage.gif) 40 | 41 | ## Local database for Comments 42 | 43 | By default your notes are backed up in a JSON file once you close VSCode. Once you open the project again, saved comments are loaded and shown on the UI. 44 | 45 | ## Breadcrumb Trails 46 | 47 | Breadcrumbs let you capture the path you follow while reverse-engineering a feature. Start a trail with `Security Notes: Create Breadcrumb Trail`, highlight the snippets you visit, and run `Security Notes: Add Breadcrumb to Trail` to drop "crumbs" along the way. Each crumb stores the code selection, file/line information, and an optional note. 48 | 49 | ![Breadcrumbs view showing a trail](images/breadcrumbs-1.png) 50 | 51 | Open the **Breadcrumbs** view from the Security Notes activity bar to see an interactive diagram of the active trail. Click any crumb in the diagram to jump back to that snippet in the editor, or switch trails from the dropdown to review other investigations. Trails are stored locally in `.security-notes-breadcrumbs.json` so you can revisit them later, and you can export the active trail to a Markdown report (via `Security Notes: Export Breadcrumb Trail` or the Export button) ready to paste into docs or reports. 52 | 53 | ![Markdown export of a Breadcrumb](images/breadcrumbs-2.png) 54 | 55 | ## Importing SAST results 56 | 57 | The extension allows you to import the output from SAST tools into notes, making the processing of the findings much easier: 58 | 59 | ![Demo for semgrep import](images/demo-semgrep-import.gif) 60 | 61 | Currently supported tools include: 62 | 63 | - bandit (https://bandit.readthedocs.io/en/latest/) 64 | - brakeman (https://brakemanscanner.org/) 65 | - checkov (https://www.checkov.io/) 66 | - gosec (https://github.com/securego/gosec) 67 | - semgrep (https://semgrep.dev/) 68 | 69 | For imports to be successful, we recommend running commands as follows (exporting results as JSON), and making sure to run these tools from the project's folder (so that all relative paths can be processed correctly): 70 | 71 | ```bash 72 | # bandit 73 | bandit -f json -o bandit-results.json -r . 74 | # brakeman 75 | brakeman -f json -o brakeman-results.json . 76 | # checkov 77 | checkov -d . -o json --output-file-path checkov-results.json 78 | # gosec 79 | gosec -fmt=json -out=gosec-results.json ./... 80 | # semgrep 81 | semgrep scan --json -o semgrep-results.json --config=auto . 82 | ``` 83 | 84 | ## Collaboration Mode 85 | 86 | Because chasing bugs with friends is more fun :) 87 | 88 | Security Notes allows sharing of notes in real-time with other users. To do so, it leverages the RethinkDB real-time database. 89 | 90 | First, make sure you have a RethinkDB database instance up and running. Then set your author name, and the database connection information in the extension's settings, and you are ready to go! Please see the section below for more details). 91 | 92 | Collaboration mode in action: 93 | 94 | ![Demo for collaboration](images/demo-collaboration.gif) 95 | 96 | ### Setting up the RethinkDB database 97 | 98 | We recommend following instructions in RethinkDB [installation guide](https://rethinkdb.com/docs/install/). Additionally, following [hardening steps](https://rethinkdb.com/docs/security/#wrapper), such as setting a password for the `admin` user and setting up SSL/TLS, are strongly encouraged. 99 | 100 | Naturally, you will want to collaborate with remote peers. To do so in a secure way, we recommend setting up access to RethinkDB via SSH or through a VPN like [Tailscale](http://tailscale.com). This way, you avoid having to expose the instance to any network, and also ensuring information in transit is encrypted. 101 | 102 | > **Important Notices:** When collaborating with others, ensure that all VSCode instances open the project from the same relative location. For example, if the source code repository you're reviewing has a directory structure like `source_code/app/src`, all peers should open VScode at the same level. Security Notes will store note location using relative paths, so they should be consistent. Also, after enabling the collaboration setting, VSCode would need to be restarted/reloaded for the change to have effect. 103 | 104 | ## Exporting notes in popular formats 105 | 106 | Currently we only support exporting notes to Markdown, but other formats such as HTML are coming soon. 107 | 108 | ## Extension Settings 109 | 110 | Various settings for the extension can be configured in VSCode's User Settings page (`CMD+Shift+P` / `Ctrl + Shift + P` -> _Preferences: Open Settings (UI)_): 111 | 112 | ![Extension Settings](images/settings.png) 113 | 114 | ## Contributing 115 | 116 | We welcome contributions to Security Notes! These are the many ways you can help: 117 | 118 | - Report bugs 119 | - Submit patches and features 120 | - Add support for additional SAST tools 121 | - Follow us on [Twitter](https://twitter.com/refactorsec) :) 122 | 123 | ## Development and Debugging 124 | 125 | - Clone the repo 126 | - Run `npm install` to install dependencies 127 | - Run the `Run Extension` target in the Debug View. This will: 128 | - Start a task `npm: watch` to compile the code 129 | - Run the extension in a new VS Code window 130 | 131 | ## Acknowledgments 132 | 133 | This project is based on the [comment-sample](https://github.com/microsoft/vscode-extension-samples/tree/main/comment-sample) extension. 134 | 135 | Additionally, the code for the note reactions was inspired by [comment-reactions](https://github.com/hacke2/vscode-extension-samples/tree/feat/comment-reactions). 136 | 137 | ## License 138 | 139 | Licensed under the [MIT](LICENSE.md) License. 140 | -------------------------------------------------------------------------------- /src/webviews/export-notes/exportNotesWebview.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as vscode from 'vscode'; 4 | import { fullPathToRelative } from '../../utils'; 5 | 6 | export class ExportNotesWebview implements vscode.WebviewViewProvider { 7 | public static readonly viewType = 'export-notes-view'; 8 | 9 | private _view?: vscode.WebviewView; 10 | private noteMap: Map; 11 | 12 | constructor( 13 | private readonly _extensionUri: vscode.Uri, 14 | noteMap: Map, 15 | ) { 16 | this.noteMap = noteMap; 17 | } 18 | 19 | public resolveWebviewView( 20 | webviewView: vscode.WebviewView, 21 | _context: vscode.WebviewViewResolveContext, 22 | _token: vscode.CancellationToken, 23 | ) { 24 | this._view = webviewView; 25 | 26 | webviewView.webview.options = { 27 | // Allow scripts in the webview 28 | enableScripts: true, 29 | localResourceRoots: [this._extensionUri], 30 | }; 31 | 32 | webviewView.webview.html = this._getHtmlForWebview(webviewView.webview); 33 | 34 | webviewView.webview.onDidReceiveMessage((data) => { 35 | switch (data.type) { 36 | case 'exportNotes': { 37 | exportNotes(data.status, data.options, data.format, this.noteMap); 38 | } 39 | } 40 | }); 41 | } 42 | 43 | private _getHtmlForWebview(webview: vscode.Webview) { 44 | const scriptUri = webview.asWebviewUri( 45 | vscode.Uri.joinPath( 46 | this._extensionUri, 47 | 'src', 48 | 'webviews', 49 | 'assets', 50 | 'exportNotes.js', 51 | ), 52 | ); 53 | const styleResetUri = webview.asWebviewUri( 54 | vscode.Uri.joinPath(this._extensionUri, 'src', 'webviews', 'assets', 'reset.css'), 55 | ); 56 | const styleVSCodeUri = webview.asWebviewUri( 57 | vscode.Uri.joinPath( 58 | this._extensionUri, 59 | 'src', 60 | 'webviews', 61 | 'assets', 62 | 'vscode.css', 63 | ), 64 | ); 65 | const styleMainUri = webview.asWebviewUri( 66 | vscode.Uri.joinPath(this._extensionUri, 'src', 'webviews', 'assets', 'main.css'), 67 | ); 68 | 69 | return ` 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 |

Select the notes you want to export:

79 |

80 | 81 |
82 | 83 |
84 | 85 |
86 | 87 |
88 |

89 |
90 | 91 |

Select additional options:

92 |

93 | 94 |
95 |

96 |

97 | 98 |
99 |

100 |

101 | 102 |
103 |

104 |
105 | 106 |

Select export format:

107 |

108 | 111 |

112 |
113 | 114 |

115 | 116 |

117 | 118 | 119 | 120 | `; 121 | } 122 | } 123 | 124 | async function exportNotes( 125 | status: any, 126 | options: any, 127 | format: string, 128 | noteMap: Map, 129 | ) { 130 | // filter notes based on selected status 131 | const selectedNotes = [...noteMap] 132 | .map(([_id, note]) => { 133 | const firstComment = note.comments[0].body.toString(); 134 | if ( 135 | (status.vulnerable && firstComment.startsWith('[Vulnerable] ')) || 136 | (status.notVulnerable && firstComment.startsWith('[Not Vulnerable] ')) || 137 | (status.todo && firstComment.startsWith('[TODO] ')) || 138 | status.noStatus 139 | ) { 140 | return note; 141 | } 142 | }) 143 | .filter((element) => element !== undefined); 144 | 145 | if (!selectedNotes.length) { 146 | vscode.window.showInformationMessage('[Export] No notes met the criteria.'); 147 | return; 148 | } 149 | 150 | switch (format) { 151 | case 'markdown': { 152 | const outputs = await Promise.all( 153 | selectedNotes.map(async (note: any) => { 154 | // include code snippet 155 | if (options.includeCodeSnippet) { 156 | const codeSnippet = await exportCodeSnippet(note.uri, note.range); 157 | return codeSnippet + exportComments(note, options); 158 | } 159 | return exportComments(note, options); 160 | }), 161 | ); 162 | 163 | const document = await vscode.workspace.openTextDocument({ 164 | content: outputs.join(''), 165 | language: 'markdown', 166 | }); 167 | vscode.window.showTextDocument(document); 168 | } 169 | } 170 | } 171 | 172 | function exportComments(note: vscode.CommentThread, options: any) { 173 | // export first comment 174 | let output = ''; 175 | output += exportComment( 176 | note.comments[0].body.toString(), 177 | options.includeAuthors ? note.comments[0].author.name : undefined, 178 | ); 179 | 180 | // include replies 181 | if (options.includeReplies) { 182 | note.comments.slice(1).forEach((comment: vscode.Comment) => { 183 | output += exportComment( 184 | comment.body.toString(), 185 | options.includeAuthors ? comment.author.name : undefined, 186 | ); 187 | }); 188 | } 189 | 190 | output += `\n-----\n`; 191 | return output; 192 | } 193 | 194 | function exportComment(body: string, author: string | undefined) { 195 | if (author) { 196 | return `\n**${author}** - ${body}\n`; 197 | } 198 | return `\n${body}\n`; 199 | } 200 | 201 | async function exportCodeSnippet(uri: vscode.Uri, range: vscode.Range) { 202 | const output = await vscode.workspace.openTextDocument(uri).then(async (document) => { 203 | const newRange = new vscode.Range(range.start.line, 0, range.end.line + 1, 0); 204 | const codeSnippet = await document.getText(newRange).trimEnd(); 205 | 206 | let lineNumbers; 207 | if (range.start.line === range.end.line) { 208 | lineNumbers = range.start.line + 1; 209 | } else { 210 | lineNumbers = `${range.start.line + 1}-${range.end.line + 1}`; 211 | } 212 | 213 | return `\nCode snippet \`${fullPathToRelative( 214 | uri.fsPath, 215 | )}:${lineNumbers}\`:\n\n\`\`\`\n${codeSnippet}\n\`\`\`\n`; 216 | }); 217 | return output; 218 | } 219 | -------------------------------------------------------------------------------- /resources/security_notes_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /src/webviews/breadcrumbs/breadcrumbsWebview.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as vscode from 'vscode'; 4 | import { BreadcrumbStore } from '../../breadcrumbs/store'; 5 | import { Crumb, Trail } from '../../models/breadcrumb'; 6 | import { formatRangeLabel, snippetPreview } from '../../breadcrumbs/format'; 7 | import { fullPathToRelative } from '../../utils'; 8 | import { revealCrumb } from '../../breadcrumbs/commands'; 9 | import { exportTrailToMarkdown } from '../../breadcrumbs/export'; 10 | 11 | interface WebviewCrumb { 12 | id: string; 13 | index: number; 14 | filePath: string; 15 | rangeLabel: string; 16 | note?: string; 17 | snippetPreview: string; 18 | snippet: string; 19 | createdAt: string; 20 | } 21 | 22 | interface WebviewTrail { 23 | id: string; 24 | name: string; 25 | description?: string; 26 | createdAt: string; 27 | updatedAt: string; 28 | crumbs: WebviewCrumb[]; 29 | } 30 | 31 | interface WebviewStatePayload { 32 | activeTrailId?: string; 33 | trails: { 34 | id: string; 35 | name: string; 36 | crumbCount: number; 37 | }[]; 38 | activeTrail?: WebviewTrail; 39 | } 40 | 41 | type WebviewMessage = 42 | | { type: 'ready' } 43 | | { type: 'openCrumb'; trailId: string; crumbId: string } 44 | | { type: 'setActiveTrail'; trailId: string } 45 | | { type: 'createTrail' } 46 | | { type: 'addCrumb' } 47 | | { type: 'exportTrail' }; 48 | 49 | export class BreadcrumbsWebview implements vscode.WebviewViewProvider, vscode.Disposable { 50 | public static readonly viewType = 'breadcrumbs-view'; 51 | 52 | private view: vscode.WebviewView | undefined; 53 | 54 | private isViewReady = false; 55 | 56 | private pendingTrailId: string | undefined; 57 | 58 | private readonly storeListener: vscode.Disposable; 59 | 60 | constructor( 61 | private readonly extensionUri: vscode.Uri, 62 | private readonly store: BreadcrumbStore, 63 | ) { 64 | this.storeListener = this.store.onDidChange(() => { 65 | this.tryPostState(); 66 | }); 67 | } 68 | 69 | public dispose() { 70 | this.storeListener.dispose(); 71 | } 72 | 73 | public reveal(trailId?: string) { 74 | if (trailId) { 75 | this.pendingTrailId = trailId; 76 | } 77 | vscode.commands.executeCommand('workbench.view.extension.view-container'); 78 | vscode.commands.executeCommand(`${BreadcrumbsWebview.viewType}.focus`); 79 | if (this.view) { 80 | this.view.show?.(true); 81 | this.tryPostState(trailId); 82 | } 83 | } 84 | 85 | public resolveWebviewView( 86 | webviewView: vscode.WebviewView, 87 | _context: vscode.WebviewViewResolveContext, 88 | _token: vscode.CancellationToken, 89 | ) { 90 | this.view = webviewView; 91 | 92 | webviewView.webview.options = { 93 | enableScripts: true, 94 | localResourceRoots: [this.extensionUri], 95 | }; 96 | 97 | webviewView.webview.html = this.getHtmlForWebview(webviewView.webview); 98 | 99 | webviewView.webview.onDidReceiveMessage((message: WebviewMessage) => { 100 | switch (message.type) { 101 | case 'ready': { 102 | this.isViewReady = true; 103 | this.tryPostState(this.pendingTrailId); 104 | this.pendingTrailId = undefined; 105 | break; 106 | } 107 | case 'openCrumb': { 108 | this.handleOpenCrumb(message.trailId, message.crumbId); 109 | break; 110 | } 111 | case 'setActiveTrail': { 112 | this.store.setActiveTrail(message.trailId); 113 | break; 114 | } 115 | case 'createTrail': { 116 | vscode.commands.executeCommand('security-notes.breadcrumbs.createTrail'); 117 | break; 118 | } 119 | /*case 'addCrumb': { 120 | vscode.commands.executeCommand('security-notes.breadcrumbs.addCrumb'); 121 | break; 122 | }*/ 123 | case 'exportTrail': { 124 | this.handleExportTrail(); 125 | break; 126 | } 127 | default: { 128 | break; 129 | } 130 | } 131 | }); 132 | 133 | webviewView.onDidDispose(() => { 134 | this.view = undefined; 135 | this.isViewReady = false; 136 | }); 137 | } 138 | 139 | private tryPostState(trailId?: string) { 140 | if (!this.view || !this.isViewReady) { 141 | if (trailId) { 142 | this.pendingTrailId = trailId; 143 | } 144 | return; 145 | } 146 | 147 | const state = this.store.getState(); 148 | const targetTrailId = trailId ?? state.activeTrailId; 149 | let activeTrail = targetTrailId 150 | ? state.trails.find((trail) => trail.id === targetTrailId) 151 | : state.trails.find((trail) => trail.id === state.activeTrailId); 152 | 153 | if (!activeTrail && state.trails.length) { 154 | activeTrail = state.trails[0]; 155 | } 156 | 157 | const payload: WebviewStatePayload = { 158 | activeTrailId: state.activeTrailId ?? activeTrail?.id, 159 | trails: state.trails.map((trail) => ({ 160 | id: trail.id, 161 | name: trail.name, 162 | crumbCount: trail.crumbs.length, 163 | })), 164 | activeTrail: activeTrail ? this.serializeTrail(activeTrail) : undefined, 165 | }; 166 | 167 | this.view.webview.postMessage({ type: 'state', payload }); 168 | } 169 | 170 | private serializeTrail(trail: Trail): WebviewTrail { 171 | return { 172 | id: trail.id, 173 | name: trail.name, 174 | description: trail.description, 175 | createdAt: trail.createdAt, 176 | updatedAt: trail.updatedAt, 177 | crumbs: trail.crumbs.map((crumb, index) => this.serializeCrumb(trail, crumb, index)), 178 | }; 179 | } 180 | 181 | private serializeCrumb(trail: Trail, crumb: Crumb, index: number): WebviewCrumb { 182 | return { 183 | id: crumb.id, 184 | index, 185 | filePath: fullPathToRelative(crumb.uri.fsPath), 186 | rangeLabel: formatRangeLabel(crumb.range), 187 | note: crumb.note, 188 | snippetPreview: snippetPreview(crumb.snippet), 189 | snippet: crumb.snippet, 190 | createdAt: crumb.createdAt, 191 | }; 192 | } 193 | 194 | private handleOpenCrumb(trailId: string, crumbId: string) { 195 | const trail = this.store.getTrail(trailId); 196 | if (!trail) { 197 | vscode.window.showErrorMessage('[Breadcrumbs] Unable to locate the requested trail.'); 198 | return; 199 | } 200 | const crumb = trail.crumbs.find((candidate) => candidate.id === crumbId); 201 | if (!crumb) { 202 | vscode.window.showErrorMessage('[Breadcrumbs] Unable to locate the requested crumb.'); 203 | return; 204 | } 205 | revealCrumb(crumb); 206 | } 207 | 208 | private async handleExportTrail() { 209 | const activeTrail = this.store.getActiveTrail(); 210 | if (!activeTrail) { 211 | vscode.window.showInformationMessage( 212 | '[Breadcrumbs] Select a trail before exporting.', 213 | ); 214 | return; 215 | } 216 | await exportTrailToMarkdown(activeTrail); 217 | } 218 | 219 | private getHtmlForWebview(webview: vscode.Webview) { 220 | const scriptUri = webview.asWebviewUri( 221 | vscode.Uri.joinPath( 222 | this.extensionUri, 223 | 'src', 224 | 'webviews', 225 | 'assets', 226 | 'breadcrumbs.js', 227 | ), 228 | ); 229 | const styleResetUri = webview.asWebviewUri( 230 | vscode.Uri.joinPath(this.extensionUri, 'src', 'webviews', 'assets', 'reset.css'), 231 | ); 232 | const styleVSCodeUri = webview.asWebviewUri( 233 | vscode.Uri.joinPath(this.extensionUri, 'src', 'webviews', 'assets', 'vscode.css'), 234 | ); 235 | const styleMainUri = webview.asWebviewUri( 236 | vscode.Uri.joinPath(this.extensionUri, 'src', 'webviews', 'assets', 'main.css'), 237 | ); 238 | const styleBreadcrumbsUri = webview.asWebviewUri( 239 | vscode.Uri.joinPath( 240 | this.extensionUri, 241 | 'src', 242 | 'webviews', 243 | 'assets', 244 | 'breadcrumbs.css', 245 | ), 246 | ); 247 | 248 | const nonce = getNonce(); 249 | 250 | return ` 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 273 |
274 | 275 | 276 |
277 | 280 | 281 | 282 | `; 283 | } 284 | } 285 | 286 | const getNonce = () => { 287 | let text = ''; 288 | const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 289 | for (let i = 0; i < 32; i += 1) { 290 | text += possible.charAt(Math.floor(Math.random() * possible.length)); 291 | } 292 | return text; 293 | }; 294 | -------------------------------------------------------------------------------- /src/breadcrumbs/commands.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as vscode from 'vscode'; 4 | import { BreadcrumbStore } from './store'; 5 | import { Crumb, Trail } from '../models/breadcrumb'; 6 | import { fullPathToRelative } from '../utils'; 7 | import { formatRangeLabel, snippetPreview } from './format'; 8 | import { exportTrailToMarkdown } from './export'; 9 | 10 | let lastActiveEditor: vscode.TextEditor | undefined = vscode.window.activeTextEditor ?? undefined; 11 | 12 | interface TrailQuickPickItem extends vscode.QuickPickItem { 13 | trail: Trail; 14 | } 15 | 16 | interface CrumbQuickPickItem extends vscode.QuickPickItem { 17 | crumb: Crumb; 18 | } 19 | 20 | const mapTrailToQuickPickItem = (trail: Trail, activeTrailId?: string): TrailQuickPickItem => ({ 21 | label: trail.name, 22 | description: trail.description, 23 | detail: `${trail.crumbs.length} crumb${trail.crumbs.length === 1 ? '' : 's'} · Last updated ${new Date( 24 | trail.updatedAt, 25 | ).toLocaleString()}`, 26 | trail, 27 | picked: trail.id === activeTrailId, 28 | }); 29 | 30 | const mapCrumbToQuickPickItem = (crumb: Crumb, index: number): CrumbQuickPickItem => ({ 31 | label: `${index + 1}. ${fullPathToRelative(crumb.uri.fsPath)}:${formatRangeLabel(crumb.range)}`, 32 | description: crumb.note, 33 | detail: snippetPreview(crumb.snippet), 34 | crumb, 35 | }); 36 | 37 | const ensureActiveTrail = async ( 38 | store: BreadcrumbStore, 39 | options: { promptUser?: boolean } = { promptUser: true }, 40 | ): Promise => { 41 | const activeTrail = store.getActiveTrail(); 42 | if (activeTrail) { 43 | return activeTrail; 44 | } 45 | 46 | const trails = store.getTrails(); 47 | if (!trails.length) { 48 | if (options.promptUser) { 49 | vscode.window.showInformationMessage( 50 | '[Breadcrumbs] No breadcrumb trails yet. Create one before adding crumbs.', 51 | ); 52 | } 53 | return undefined; 54 | } 55 | 56 | if (!options.promptUser) { 57 | return undefined; 58 | } 59 | 60 | const picked = await vscode.window.showQuickPick( 61 | trails.map((trail) => mapTrailToQuickPickItem(trail, store.getState().activeTrailId)), 62 | { 63 | placeHolder: 'Select a breadcrumb trail to work with', 64 | }, 65 | ); 66 | 67 | if (!picked) { 68 | return undefined; 69 | } 70 | 71 | store.setActiveTrail(picked.trail.id); 72 | return store.getTrail(picked.trail.id); 73 | }; 74 | 75 | const promptForTrail = async (store: BreadcrumbStore, placeHolder: string) => { 76 | const trails = store.getTrails(); 77 | if (!trails.length) { 78 | vscode.window.showInformationMessage('[Breadcrumbs] No trails available. Create one first.'); 79 | return undefined; 80 | } 81 | const picked = await vscode.window.showQuickPick( 82 | trails.map((trail) => mapTrailToQuickPickItem(trail, store.getState().activeTrailId)), 83 | { placeHolder }, 84 | ); 85 | return picked?.trail; 86 | }; 87 | 88 | const promptForCrumb = async (trail: Trail, placeHolder: string): Promise => { 89 | if (!trail.crumbs.length) { 90 | vscode.window.showInformationMessage('[Breadcrumbs] The selected trail has no crumbs yet.'); 91 | return undefined; 92 | } 93 | const picked = await vscode.window.showQuickPick( 94 | trail.crumbs.map((crumb, index) => mapCrumbToQuickPickItem(crumb, index)), 95 | { placeHolder }, 96 | ); 97 | return picked?.crumb; 98 | }; 99 | 100 | export const revealCrumb = async (crumb: Crumb) => { 101 | const document = await vscode.workspace.openTextDocument(crumb.uri); 102 | const editor = await vscode.window.showTextDocument(document, { preview: false }); 103 | const selection = new vscode.Selection(crumb.range.start, crumb.range.end); 104 | editor.selection = selection; 105 | editor.revealRange(crumb.range, vscode.TextEditorRevealType.InCenter); 106 | }; 107 | 108 | interface RegisterBreadcrumbCommandsOptions { 109 | onShowTrailDiagram?: (trail: Trail) => Promise | void; 110 | onExportTrail?: (trail: Trail) => Promise | void; 111 | } 112 | 113 | export const registerBreadcrumbCommands = ( 114 | context: vscode.ExtensionContext, 115 | store: BreadcrumbStore, 116 | options: RegisterBreadcrumbCommandsOptions = {}, 117 | ) => { 118 | const disposables: vscode.Disposable[] = []; 119 | 120 | disposables.push( 121 | vscode.window.onDidChangeActiveTextEditor((editor) => { 122 | if (editor) { 123 | lastActiveEditor = editor; 124 | } 125 | }), 126 | ); 127 | 128 | disposables.push( 129 | vscode.commands.registerCommand('security-notes.breadcrumbs.createTrail', async () => { 130 | const name = await vscode.window.showInputBox({ 131 | prompt: 'Name for the new breadcrumb trail', 132 | placeHolder: 'e.g. User login flow', 133 | ignoreFocusOut: true, 134 | validateInput: (value) => (!value?.trim().length ? 'Trail name is required.' : undefined), 135 | }); 136 | if (!name) { 137 | return; 138 | } 139 | const description = await vscode.window.showInputBox({ 140 | prompt: 'Optional description', 141 | placeHolder: 'What does this trail capture?', 142 | ignoreFocusOut: true, 143 | }); 144 | const trail = store.createTrail(name.trim(), { 145 | description: description?.trim() ? description.trim() : undefined, 146 | setActive: true, 147 | }); 148 | vscode.window.showInformationMessage( 149 | `[Breadcrumbs] Created trail "${trail.name}" and set it as active.`, 150 | ); 151 | }), 152 | ); 153 | 154 | disposables.push( 155 | vscode.commands.registerCommand('security-notes.breadcrumbs.selectTrail', async () => { 156 | const trail = await promptForTrail(store, 'Select the breadcrumb trail to activate'); 157 | if (!trail) { 158 | return; 159 | } 160 | store.setActiveTrail(trail.id); 161 | vscode.window.showInformationMessage( 162 | `[Breadcrumbs] Active trail set to "${trail.name}".`, 163 | ); 164 | }), 165 | ); 166 | 167 | disposables.push( 168 | vscode.commands.registerCommand('security-notes.breadcrumbs.addCrumb', async () => { 169 | const editor = vscode.window.activeTextEditor ?? lastActiveEditor; 170 | if (!editor) { 171 | vscode.window.showInformationMessage( 172 | '[Breadcrumbs] Open a file and select the code you want to add as a crumb.', 173 | ); 174 | return; 175 | } 176 | 177 | await vscode.commands.executeCommand('workbench.action.focusActiveEditorGroup'); 178 | 179 | const trail = await ensureActiveTrail(store); 180 | if (!trail) { 181 | return; 182 | } 183 | 184 | const selection = editor.selection; 185 | const document = editor.document; 186 | const range = selection.isEmpty 187 | ? document.lineAt(selection.start.line).range 188 | : new vscode.Range(selection.start, selection.end); 189 | const snippet = selection.isEmpty 190 | ? document.lineAt(selection.start.line).text 191 | : document.getText(selection); 192 | 193 | if (!snippet.trim().length) { 194 | vscode.window.showInformationMessage( 195 | '[Breadcrumbs] The selected snippet is empty. Expand the selection and try again.', 196 | ); 197 | return; 198 | } 199 | 200 | const note = await vscode.window.showInputBox({ 201 | prompt: 'Optional note for this crumb', 202 | placeHolder: 'Why is this snippet important?', 203 | ignoreFocusOut: true, 204 | }); 205 | 206 | const crumb = store.addCrumb(trail.id, document.uri, range, snippet, { 207 | note: note?.trim() ? note.trim() : undefined, 208 | }); 209 | 210 | if (!crumb) { 211 | vscode.window.showErrorMessage('[Breadcrumbs] Failed to add crumb to the trail.'); 212 | return; 213 | } 214 | 215 | vscode.window.showInformationMessage( 216 | `[Breadcrumbs] Added crumb to "${trail.name}" at ${fullPathToRelative( 217 | crumb.uri.fsPath, 218 | )}:${formatRangeLabel(crumb.range)}.`, 219 | 'View', 220 | ).then((selectionAction) => { 221 | if (selectionAction === 'View') { 222 | revealCrumb(crumb); 223 | } 224 | }); 225 | }), 226 | ); 227 | 228 | disposables.push( 229 | vscode.commands.registerCommand('security-notes.breadcrumbs.removeCrumb', async () => { 230 | const trail = await ensureActiveTrail(store); 231 | if (!trail) { 232 | return; 233 | } 234 | const crumb = await promptForCrumb(trail, 'Select the crumb to remove'); 235 | if (!crumb) { 236 | return; 237 | } 238 | store.removeCrumb(trail.id, crumb.id); 239 | vscode.window.showInformationMessage( 240 | `[Breadcrumbs] Removed crumb from "${trail.name}".`, 241 | ); 242 | }), 243 | ); 244 | 245 | disposables.push( 246 | vscode.commands.registerCommand('security-notes.breadcrumbs.editCrumbNote', async () => { 247 | const trail = await ensureActiveTrail(store); 248 | if (!trail) { 249 | return; 250 | } 251 | const crumb = await promptForCrumb(trail, 'Select the crumb to edit'); 252 | if (!crumb) { 253 | return; 254 | } 255 | const note = await vscode.window.showInputBox({ 256 | prompt: 'Update the crumb note', 257 | value: crumb.note, 258 | ignoreFocusOut: true, 259 | }); 260 | store.updateCrumbNote(trail.id, crumb.id, note?.trim() ? note.trim() : undefined); 261 | vscode.window.showInformationMessage('[Breadcrumbs] Updated crumb note.'); 262 | }), 263 | ); 264 | 265 | disposables.push( 266 | vscode.commands.registerCommand('security-notes.breadcrumbs.showTrailDiagram', async () => { 267 | const trail = await ensureActiveTrail(store); 268 | if (!trail) { 269 | return; 270 | } 271 | if (options.onShowTrailDiagram) { 272 | await options.onShowTrailDiagram(trail); 273 | } else { 274 | vscode.window.showInformationMessage( 275 | '[Breadcrumbs] Diagram view is not available yet in this session.', 276 | ); 277 | } 278 | }), 279 | ); 280 | 281 | disposables.push( 282 | vscode.commands.registerCommand('security-notes.breadcrumbs.exportTrail', async () => { 283 | const trail = await ensureActiveTrail(store); 284 | if (!trail) { 285 | return; 286 | } 287 | if (options.onExportTrail) { 288 | await options.onExportTrail(trail); 289 | } else { 290 | await exportTrailToMarkdown(trail); 291 | } 292 | }), 293 | ); 294 | 295 | disposables.forEach((disposable) => context.subscriptions.push(disposable)); 296 | }; 297 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "security-notes", 3 | "displayName": "Security Notes", 4 | "description": "Security Notes streamlines security code reviews by letting you drop rich, inline comments directly in source files, tag each finding with TODO/Vulnerable/Not Vulnerable statuses, and autosave everything to a shareable local database; you can import SAST tool results, collaborate in real time via RethinkDB sync, track investigative breadcrumbs, and export organized reports—all without leaving VS Code.", 5 | "icon": "resources/security_notes_logo.png", 6 | "version": "1.4.0", 7 | "publisher": "refactor-security", 8 | "private": false, 9 | "license": "MIT", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/RefactorSecurity/vscode-security-notes" 13 | }, 14 | "engines": { 15 | "vscode": "^1.65.0" 16 | }, 17 | "categories": [ 18 | "Other" 19 | ], 20 | "activationEvents": [ 21 | "onStartupFinished" 22 | ], 23 | "main": "./out/extension.js", 24 | "contributes": { 25 | "commands": [ 26 | { 27 | "command": "security-notes.createNote", 28 | "title": "Create Note", 29 | "enablement": "!commentIsEmpty" 30 | }, 31 | { 32 | "command": "security-notes.replyNoteComment", 33 | "title": "Reply", 34 | "enablement": "!commentIsEmpty" 35 | }, 36 | { 37 | "command": "security-notes.editNoteComment", 38 | "title": "Edit", 39 | "icon": { 40 | "dark": "resources/edit_inverse.svg", 41 | "light": "resources/edit.svg" 42 | } 43 | }, 44 | { 45 | "command": "security-notes.deleteNote", 46 | "title": "Delete", 47 | "icon": { 48 | "dark": "resources/close_inverse.svg", 49 | "light": "resources/close.svg" 50 | } 51 | }, 52 | { 53 | "command": "security-notes.deleteNoteComment", 54 | "title": "Delete", 55 | "icon": { 56 | "dark": "resources/close_inverse.svg", 57 | "light": "resources/close.svg" 58 | } 59 | }, 60 | { 61 | "command": "security-notes.saveEditNoteComment", 62 | "title": "Save" 63 | }, 64 | { 65 | "command": "security-notes.cancelEditNoteComment", 66 | "title": "Cancel" 67 | }, 68 | { 69 | "command": "security-notes.setNoteStatusVulnerable", 70 | "title": "Mark as Vulnerable" 71 | }, 72 | { 73 | "command": "security-notes.setNoteStatusNotVulnerable", 74 | "title": "Mark as Not Vulnerable" 75 | }, 76 | { 77 | "command": "security-notes.setNoteStatusToDo", 78 | "title": "Mark as To-Do" 79 | }, 80 | { 81 | "command": "security-notes.saveNotesToFile", 82 | "title": "Security-Notes: Save Notes to Local Database" 83 | }, 84 | { 85 | "command": "security-notes.breadcrumbs.createTrail", 86 | "title": "Security Notes: Create Breadcrumb Trail" 87 | }, 88 | { 89 | "command": "security-notes.breadcrumbs.selectTrail", 90 | "title": "Security Notes: Select Active Breadcrumb Trail" 91 | }, 92 | { 93 | "command": "security-notes.breadcrumbs.addCrumb", 94 | "title": "Security Notes: Add Breadcrumb to Trail" 95 | }, 96 | { 97 | "command": "security-notes.breadcrumbs.removeCrumb", 98 | "title": "Security Notes: Remove Breadcrumb Crumb" 99 | }, 100 | { 101 | "command": "security-notes.breadcrumbs.editCrumbNote", 102 | "title": "Security Notes: Edit Breadcrumb Note" 103 | }, 104 | { 105 | "command": "security-notes.breadcrumbs.showTrailDiagram", 106 | "title": "Security Notes: Show Breadcrumb Diagram" 107 | }, 108 | { 109 | "command": "security-notes.breadcrumbs.exportTrail", 110 | "title": "Security Notes: Export Breadcrumb Trail" 111 | } 112 | ], 113 | "configuration": { 114 | "title": "Security Notes", 115 | "properties": { 116 | "security-notes.authorName": { 117 | "type": "string", 118 | "description": "Author name for comments.", 119 | "default": "User" 120 | }, 121 | "security-notes.localDatabase": { 122 | "type": "string", 123 | "description": "Local database file path.", 124 | "default": ".security-notes.json" 125 | }, 126 | "security-notes.breadcrumbs.localDatabase": { 127 | "type": "string", 128 | "description": "Local database file path for breadcrumb trails.", 129 | "default": ".security-notes-breadcrumbs.json" 130 | }, 131 | "security-notes.collab.enabled": { 132 | "type": "boolean", 133 | "description": "Enable collaboration via RethinkDB.", 134 | "default": false 135 | }, 136 | "security-notes.collab.host": { 137 | "type": "string", 138 | "description": "Hostname for the RethinkDB connection.", 139 | "default": "localhost" 140 | }, 141 | "security-notes.collab.port": { 142 | "type": "number", 143 | "description": "Port number for the RethinkDB connection.", 144 | "default": 28015 145 | }, 146 | "security-notes.collab.database": { 147 | "type": "string", 148 | "description": "Name of the RethinkDB database.", 149 | "default": "security-notes" 150 | }, 151 | "security-notes.collab.username": { 152 | "type": "string", 153 | "description": "Username for the RethinkDB connection.", 154 | "default": "admin" 155 | }, 156 | "security-notes.collab.password": { 157 | "type": "string", 158 | "description": "Password for the RethinkDB connection.", 159 | "default": "" 160 | }, 161 | "security-notes.collab.ssl": { 162 | "type": "string", 163 | "description": "SSL/TLS certificate file path for the RethinkDB connection (optional).", 164 | "default": "" 165 | }, 166 | "security-notes.collab.projectName": { 167 | "type": "string", 168 | "description": "Project name used as the RethinkDB table.", 169 | "default": "project" 170 | } 171 | } 172 | }, 173 | "menus": { 174 | "commandPalette": [ 175 | { 176 | "command": "security-notes.createNote", 177 | "when": "false" 178 | }, 179 | { 180 | "command": "security-notes.replyNoteComment", 181 | "when": "false" 182 | }, 183 | { 184 | "command": "security-notes.deleteNote", 185 | "when": "false" 186 | }, 187 | { 188 | "command": "security-notes.deleteNoteComment", 189 | "when": "false" 190 | }, 191 | { 192 | "command": "security-notes.setNoteStatusVulnerable", 193 | "when": "false" 194 | }, 195 | { 196 | "command": "security-notes.setNoteStatusNotVulnerable", 197 | "when": "false" 198 | }, 199 | { 200 | "command": "security-notes.setNoteStatusToDo", 201 | "when": "false" 202 | } 203 | ], 204 | "comments/commentThread/title": [ 205 | { 206 | "command": "security-notes.deleteNote", 207 | "group": "navigation", 208 | "when": "commentController == security-notes && !commentThreadIsEmpty" 209 | } 210 | ], 211 | "comments/commentThread/context": [ 212 | { 213 | "command": "security-notes.createNote", 214 | "group": "inline", 215 | "when": "commentController == security-notes && commentThreadIsEmpty" 216 | }, 217 | { 218 | "command": "security-notes.setNoteStatusVulnerable", 219 | "group": "inline@4", 220 | "when": "commentController == security-notes && !commentThreadIsEmpty" 221 | }, 222 | { 223 | "command": "security-notes.setNoteStatusNotVulnerable", 224 | "group": "inline@3", 225 | "when": "commentController == security-notes && !commentThreadIsEmpty" 226 | }, 227 | { 228 | "command": "security-notes.setNoteStatusToDo", 229 | "group": "inline@2", 230 | "when": "commentController == security-notes && !commentThreadIsEmpty" 231 | }, 232 | { 233 | "command": "security-notes.replyNoteComment", 234 | "group": "inline@1", 235 | "when": "commentController == security-notes && !commentThreadIsEmpty" 236 | } 237 | ], 238 | "comments/comment/title": [ 239 | { 240 | "command": "security-notes.editNoteComment", 241 | "group": "group@1", 242 | "when": "commentController == security-notes" 243 | }, 244 | { 245 | "command": "security-notes.deleteNoteComment", 246 | "group": "group@2", 247 | "when": "commentController == security-notes && comment == canDelete" 248 | } 249 | ], 250 | "comments/comment/context": [ 251 | { 252 | "command": "security-notes.cancelEditNoteComment", 253 | "group": "inline@1", 254 | "when": "commentController == security-notes" 255 | }, 256 | { 257 | "command": "security-notes.saveEditNoteComment", 258 | "group": "inline@2", 259 | "when": "commentController == security-notes" 260 | } 261 | ], 262 | "editor/context": [ 263 | { 264 | "command": "security-notes.breadcrumbs.addCrumb", 265 | "group": "navigation@10", 266 | "when": "editorHasSelection" 267 | } 268 | ] 269 | }, 270 | "views": { 271 | "view-container": [ 272 | { 273 | "type": "webview", 274 | "name": "Import Tool Results", 275 | "id": "import-tool-results-view" 276 | }, 277 | { 278 | "type": "webview", 279 | "name": "Export Notes", 280 | "id": "export-notes-view" 281 | }, 282 | { 283 | "type": "webview", 284 | "name": "Breadcrumbs", 285 | "id": "breadcrumbs-view" 286 | } 287 | ] 288 | }, 289 | "viewsContainers": { 290 | "activitybar": [ 291 | { 292 | "id": "view-container", 293 | "title": "Security Notes", 294 | "icon": "resources/security_notes_icon.svg" 295 | } 296 | ] 297 | } 298 | }, 299 | "scripts": { 300 | "vscode:prepublish": "npm run compile", 301 | "compile": "tsc -p ./", 302 | "watch": "tsc -watch -p ./", 303 | "lint": "eslint \"src/**/*.ts\"" 304 | }, 305 | "devDependencies": { 306 | "@types/node": "^16.11.7", 307 | "@types/rethinkdb": "^2.3.17", 308 | "@types/vscode": "~1.65.0", 309 | "@typescript-eslint/eslint-plugin": "^5.42.0", 310 | "@typescript-eslint/parser": "^5.42.0", 311 | "eslint": "^8.32.0", 312 | "eslint-config-airbnb": "^19.0.4", 313 | "eslint-config-airbnb-base": "^15.0.0", 314 | "eslint-config-prettier": "^8.6.0", 315 | "prettier-linter-helpers": "^1.0.0", 316 | "typescript": "^4.8.4" 317 | }, 318 | "dependencies": { 319 | "@types/uuid": "^9.0.0", 320 | "rethinkdb": "^2.4.2", 321 | "uuid": "^9.0.0" 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as vscode from 'vscode'; 4 | import { NoteStatus } from './models/noteStatus'; 5 | import { NoteComment } from './models/noteComment'; 6 | import { Resource } from './reactions/resource'; 7 | import { ImportToolResultsWebview } from './webviews/import-tool-results/importToolResultsWebview'; 8 | import { ExportNotesWebview } from './webviews/export-notes/exportNotesWebview'; 9 | import { commentController } from './controllers/comments'; 10 | import { reactionHandler } from './handlers/reaction'; 11 | import { getSetting } from './utils'; 12 | import { 13 | saveNoteComment, 14 | setNoteStatus, 15 | syncNoteMapWithRemote, 16 | } from './helpers'; 17 | import { RemoteDb } from './persistence/remote-db'; 18 | import { loadNotesFromFile, saveNotesToFile } from './persistence/local-db'; 19 | import { BreadcrumbStore } from './breadcrumbs/store'; 20 | import { registerBreadcrumbCommands } from './breadcrumbs/commands'; 21 | import { 22 | loadBreadcrumbsFromFile, 23 | saveBreadcrumbsToFile, 24 | } from './persistence/local-db/breadcrumbs'; 25 | import { BreadcrumbsWebview } from './webviews/breadcrumbs/breadcrumbsWebview'; 26 | 27 | const noteMap = new Map(); 28 | let remoteDb: RemoteDb | undefined; 29 | const breadcrumbStore = new BreadcrumbStore(); 30 | 31 | export function activate(context: vscode.ExtensionContext) { 32 | Resource.initialize(context); 33 | const persistedBreadcrumbState = loadBreadcrumbsFromFile(); 34 | breadcrumbStore.replaceState(persistedBreadcrumbState); 35 | const breadcrumbStoreSubscription = breadcrumbStore.onDidChange(() => { 36 | saveBreadcrumbsToFile(breadcrumbStore); 37 | }); 38 | context.subscriptions.push(breadcrumbStoreSubscription); 39 | 40 | const breadcrumbsWebview = new BreadcrumbsWebview( 41 | context.extensionUri, 42 | breadcrumbStore, 43 | ); 44 | context.subscriptions.push( 45 | vscode.window.registerWebviewViewProvider( 46 | BreadcrumbsWebview.viewType, 47 | breadcrumbsWebview, 48 | ), 49 | breadcrumbsWebview, 50 | ); 51 | 52 | registerBreadcrumbCommands(context, breadcrumbStore, { 53 | onShowTrailDiagram: async (trail) => { 54 | breadcrumbStore.setActiveTrail(trail.id); 55 | breadcrumbsWebview.reveal(trail.id); 56 | }, 57 | }); 58 | if (getSetting('collab.enabled')) { 59 | remoteDb = new RemoteDb( 60 | getSetting('collab.host'), 61 | getSetting('collab.port'), 62 | getSetting('collab.username'), 63 | getSetting('collab.password'), 64 | getSetting('collab.database'), 65 | getSetting('collab.projectName'), 66 | getSetting('collab.ssl'), 67 | noteMap, 68 | ); 69 | } else { 70 | remoteDb = undefined; 71 | } 72 | 73 | // A `CommentController` is able to provide comments for documents. 74 | context.subscriptions.push(commentController); 75 | 76 | // A `CommentingRangeProvider` controls where gutter decorations that allow adding comments are shown 77 | commentController.commentingRangeProvider = { 78 | provideCommentingRanges: ( 79 | document: vscode.TextDocument, 80 | token: vscode.CancellationToken, 81 | ) => { 82 | const lineCount = document.lineCount; 83 | return [new vscode.Range(0, 0, lineCount - 1, 0)]; 84 | }, 85 | }; 86 | 87 | // reaction handler 88 | commentController.reactionHandler = reactionHandler; 89 | 90 | // save notes to file handler 91 | context.subscriptions.push( 92 | vscode.commands.registerCommand('security-notes.saveNotesToFile', () => 93 | saveNotesToFile(noteMap), 94 | ), 95 | ); 96 | 97 | // create note button 98 | context.subscriptions.push( 99 | vscode.commands.registerCommand( 100 | 'security-notes.createNote', 101 | (reply: vscode.CommentReply) => { 102 | saveNoteComment(reply.thread, reply.text, true, noteMap, '', remoteDb); 103 | }, 104 | ), 105 | ); 106 | 107 | // reply note comment button 108 | context.subscriptions.push( 109 | vscode.commands.registerCommand( 110 | 'security-notes.replyNoteComment', 111 | (reply: vscode.CommentReply) => { 112 | saveNoteComment(reply.thread, reply.text, false, noteMap, '', remoteDb); 113 | }, 114 | ), 115 | ); 116 | 117 | // delete note comment button 118 | context.subscriptions.push( 119 | vscode.commands.registerCommand( 120 | 'security-notes.deleteNoteComment', 121 | (comment: NoteComment) => { 122 | const thread = comment.parent; 123 | if (!thread) { 124 | return; 125 | } 126 | 127 | let commentRemoved = false; 128 | thread.comments = thread.comments.filter( 129 | (cmt) => { 130 | const shouldKeep = (cmt as NoteComment).id !== comment.id; 131 | if (!shouldKeep) { 132 | commentRemoved = true; 133 | } 134 | return shouldKeep; 135 | }, 136 | ); 137 | 138 | if (thread.comments.length === 0) { 139 | if (thread.contextValue) { 140 | noteMap.delete(thread.contextValue); 141 | } 142 | thread.dispose(); 143 | } 144 | 145 | if (commentRemoved) { 146 | saveNotesToFile(noteMap); 147 | } 148 | }, 149 | ), 150 | ); 151 | 152 | // delete note button 153 | context.subscriptions.push( 154 | vscode.commands.registerCommand( 155 | 'security-notes.deleteNote', 156 | (thread: vscode.CommentThread) => { 157 | thread.dispose(); 158 | if (thread.contextValue) { 159 | noteMap.delete(thread.contextValue); 160 | saveNotesToFile(noteMap); 161 | } 162 | }, 163 | ), 164 | ); 165 | 166 | // cancel edit note comment button 167 | context.subscriptions.push( 168 | vscode.commands.registerCommand( 169 | 'security-notes.cancelEditNoteComment', 170 | (comment: NoteComment) => { 171 | if (!comment.parent) { 172 | return; 173 | } 174 | 175 | comment.parent.comments = comment.parent.comments.map((cmt) => { 176 | if ((cmt as NoteComment).id === comment.id) { 177 | cmt.body = (cmt as NoteComment).savedBody; 178 | cmt.mode = vscode.CommentMode.Preview; 179 | } 180 | 181 | return cmt; 182 | }); 183 | }, 184 | ), 185 | ); 186 | 187 | // save edit note comment button 188 | context.subscriptions.push( 189 | vscode.commands.registerCommand( 190 | 'security-notes.saveEditNoteComment', 191 | (comment: NoteComment) => { 192 | if (!comment.parent) { 193 | return; 194 | } 195 | 196 | let commentUpdated = false; 197 | comment.parent.comments = comment.parent.comments.map((cmt) => { 198 | if ((cmt as NoteComment).id === comment.id) { 199 | (cmt as NoteComment).savedBody = cmt.body; 200 | cmt.mode = vscode.CommentMode.Preview; 201 | commentUpdated = true; 202 | } 203 | return cmt; 204 | }); 205 | 206 | if (commentUpdated) { 207 | saveNotesToFile(noteMap); 208 | if (remoteDb) { 209 | remoteDb.pushNoteComment(comment.parent, false); 210 | } 211 | } 212 | }, 213 | ), 214 | ); 215 | 216 | // edit note comment button 217 | context.subscriptions.push( 218 | vscode.commands.registerCommand( 219 | 'security-notes.editNoteComment', 220 | (comment: NoteComment) => { 221 | if (!comment.parent) { 222 | return; 223 | } 224 | 225 | comment.parent.comments = comment.parent.comments.map((cmt) => { 226 | if ((cmt as NoteComment).id === comment.id) { 227 | cmt.mode = vscode.CommentMode.Editing; 228 | } 229 | 230 | return cmt; 231 | }); 232 | }, 233 | ), 234 | ); 235 | 236 | /** 237 | * Handles the common logic for setting a note's status via a command. 238 | * 239 | * @param reply The argument passed by the command (either CommentReply or just the thread). 240 | * @param status The NoteStatus to set (TODO, Vulnerable, Not Vulnerable). 241 | * @param noteMap The object storing all notes in memory. 242 | * @param remoteDb Remote db for collaboration. 243 | */ 244 | const handleSetStatusAction = ( 245 | reply: vscode.CommentReply | { thread: vscode.CommentThread }, 246 | status: NoteStatus, 247 | noteMap: Map, 248 | remoteDb?: RemoteDb 249 | ) => { 250 | const thread = reply.thread; 251 | // Extract the text of the reply box 252 | const text = 'text' in reply ? reply.text : undefined; 253 | 254 | // Set the status (this function handles adding the status change comment) 255 | setNoteStatus( 256 | thread, 257 | status, // New status to set 258 | noteMap, 259 | '', 260 | remoteDb, 261 | text // Reply text 262 | ); 263 | }; 264 | 265 | // --- Register the status commands --- 266 | 267 | // Set note status as Vulnerable button 268 | context.subscriptions.push( 269 | vscode.commands.registerCommand( 270 | 'security-notes.setNoteStatusVulnerable', 271 | (reply: vscode.CommentReply | { thread: vscode.CommentThread }) => 272 | handleSetStatusAction(reply, NoteStatus.Vulnerable, noteMap, remoteDb) 273 | ) 274 | ); 275 | 276 | // Set note status as Not Vulnerable button 277 | context.subscriptions.push( 278 | vscode.commands.registerCommand( 279 | 'security-notes.setNoteStatusNotVulnerable', 280 | (reply: vscode.CommentReply | { thread: vscode.CommentThread }) => 281 | handleSetStatusAction(reply, NoteStatus.NotVulnerable, noteMap, remoteDb) 282 | ) 283 | ); 284 | 285 | // Set note status as TODO button 286 | context.subscriptions.push( 287 | vscode.commands.registerCommand( 288 | 'security-notes.setNoteStatusToDo', 289 | (reply: vscode.CommentReply | { thread: vscode.CommentThread }) => 290 | handleSetStatusAction(reply, NoteStatus.TODO, noteMap, remoteDb) 291 | ) 292 | ); 293 | 294 | // webview for importing tool results 295 | const importToolResultsWebview = new ImportToolResultsWebview( 296 | context.extensionUri, 297 | noteMap, 298 | remoteDb, 299 | ); 300 | context.subscriptions.push( 301 | vscode.window.registerWebviewViewProvider( 302 | ImportToolResultsWebview.viewType, 303 | importToolResultsWebview, 304 | ), 305 | ); 306 | 307 | // webview for exporting notes 308 | const exportNotesWebview = new ExportNotesWebview(context.extensionUri, noteMap); 309 | context.subscriptions.push( 310 | vscode.window.registerWebviewViewProvider( 311 | ExportNotesWebview.viewType, 312 | exportNotesWebview, 313 | ), 314 | ); 315 | 316 | // load persisted comments from file 317 | const persistedThreads = loadNotesFromFile(); 318 | persistedThreads.forEach((thread) => { 319 | noteMap.set(thread.contextValue ? thread.contextValue : '', thread); 320 | }); 321 | 322 | // initial retrieval of notes from database 323 | setTimeout(() => { 324 | if (remoteDb) { 325 | remoteDb.retrieveAll().then((remoteThreads) => { 326 | syncNoteMapWithRemote(noteMap, remoteThreads, remoteDb); 327 | }); 328 | } 329 | }, 1500); 330 | } 331 | 332 | export function deactivate(context: vscode.ExtensionContext) { 333 | // persist comments in file 334 | saveNotesToFile(noteMap); 335 | saveBreadcrumbsToFile(breadcrumbStore); 336 | } 337 | --------------------------------------------------------------------------------