,
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 | Vulnerable
82 |
83 | Not Vulnerable
84 |
85 | TODO
86 |
87 | No Status
88 |
89 |
90 |
91 | Select additional options:
92 |
93 |
94 | Include code snippet
95 |
96 |
97 |
98 | Include note replies
99 |
100 |
101 |
102 | Include authors
103 |
104 |
105 |
106 | Select export format:
107 |
108 |
109 | Markdown
110 |
111 |
112 |
113 |
114 |
115 | Export
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 | Active trail
275 |
276 |
277 |
278 | Loading breadcrumbs...
279 |
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 |
--------------------------------------------------------------------------------