commit 096c9aea57d3f1118cfb3c98f5974a2da8629003 Author: Michael Scovetta Date: Mon Oct 17 12:37:59 2016 -0700 Initial import from internal repository. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2cd1d7a --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +rules/* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..407746c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,51 @@ +## Contributing Issues + +### Before Submitting an Issue + +First, please do a search of [open issues](https://github.com/Microsoft/DevSkim-SublimeText/issues) to see +if the issue or feature request has already been filed. Use this +[query](https://github.com/Microsoft/DevSkim-SublimeText/issues?q=is%3Aopen+is%3Aissue+label%3Afeature-request+sort%3Areactions-%2B1-desc) +to search for the most popular feature requests. + +If you find your issue already exists, make relevant comments and add your +[reaction](https://github.com/blog/2119-add-reactions-to-pull-requests-issues-and-comments). Use a reaction in place of a "+1" comment. + +👍 - upvote + +👎 - downvote + +The DevSkim project is distributed across multiple repositories, so try to file the issue against the correct repository: + +* [DevSkim-SublimeText](https://github.com/Microsoft/DevSkim-SublimeText/) - Sublime Text Plugin +* [DevSkim-VSCode](https://github.com/Microsoft/DevSkim-VSCode/) - VSCode Plugin +* [DevSkim-VisualStudio](https://github.com/Microsoft/DevSkim-VisualStudio/) - Visual Studio Plugin +* [DevSkim-Rules](https://github.com/Microsoft/DevSkim-Rules/) - Common Rules + +If your issue is a question then please ask the question on [Stack Overflow](https://stackoverflow.com/questions/tagged/devskim) +using the tag `devskim`. + +If you cannot find an existing issue that describes your bug or feature, submit an issue using the guidelines below. + +## Writing Good Bug Reports and Feature Requests + +File a single issue per problem and feature request. + +* Do not enumerate multiple bugs or feature requests in the same issue. +* Do not add your issue as a comment to an existing issue unless it's for the identical input. Many issues look similar, but have different causes. +* Turning on `debug` mode can be helpful in providing more information (Package Settings | DevSkim | Settings - User | set `"debug": true`) + +The more information you can provide, the more likely someone will be successful reproducing the issue and finding a fix. Therefore: + +* Provide reproducible steps, what the result of the steps was, and what you would have expected. +* A description of what you expect to happen +* Code that demonstrates the issue, when providing a code snippet also include it in source and not only as an image +* Version of DevSkim and Sublime Text +* Errors in the Sublime Text Console (View | Show Console) + +Don't feel bad if we can't reproduce the issue and ask for more information! + +## Contributing Fixes + +If you are interested in fixing issues and contributing directly to the code base, +please see the document [How to Contribute](https://github.com/Microsoft/DevSkim-SublimeText/wiki/How-to-Contribute). + diff --git a/Default (Linux).sublime-keymap b/Default (Linux).sublime-keymap new file mode 100644 index 0000000..5206454 --- /dev/null +++ b/Default (Linux).sublime-keymap @@ -0,0 +1,8 @@ +[ + { + "keys": [ "ctrl+shift+d" ], + "command": "dev_skim_analyze", + "context": [ + ] + } +] \ No newline at end of file diff --git a/Default (OSX).sublime-keymap b/Default (OSX).sublime-keymap new file mode 100644 index 0000000..a4885a0 --- /dev/null +++ b/Default (OSX).sublime-keymap @@ -0,0 +1,8 @@ +[ + { + "keys": [ "super+shift+d" ], + "command": "dev_skim_analyze", + "context": [ + ] + } +] \ No newline at end of file diff --git a/Default (Windows).sublime-keymap b/Default (Windows).sublime-keymap new file mode 100644 index 0000000..5206454 --- /dev/null +++ b/Default (Windows).sublime-keymap @@ -0,0 +1,8 @@ +[ + { + "keys": [ "ctrl+shift+d" ], + "command": "dev_skim_analyze", + "context": [ + ] + } +] \ No newline at end of file diff --git a/DevSkim.py b/DevSkim.py new file mode 100644 index 0000000..91dc046 --- /dev/null +++ b/DevSkim.py @@ -0,0 +1,945 @@ +""" +Copyright (c) 2016 Microsoft. All rights reserved. +Licensed under the MIT License. See LICENSE.txt in the project root for license information. + +DevSkim Sublime Text Plugin +""" + +import datetime +import json +import logging +import re +import time +import os +import traceback +import webbrowser + +try: + import sublime + import sublime_plugin +except: + print("Unable to import Sublime Text modules. DevSkim must be run within Sublime Text.") + import sys + sys.exit(1) + +# Non-configurable settings +MIN_ST_VERSION = 3114 +MARK_FLAGS = sublime.DRAW_NO_FILL | sublime.HIDE_ON_MINIMAP +LOG_FORMAT = '%(asctime)-15s: %(levelname)s: %(name)s.%(funcName)s: %(message)s' +SEVERITY_LIST = ["critical", "important", "moderate", "low", + "defense-in-depth", "informational"] + +PACKAGE_DIR = os.path.join(sublime.packages_path(), os.path.basename(os.path.dirname(__file__))) +RULE_DIRECTORY = ['default', 'custom'] + +# Minimum Sublime Text version we support +if int(sublime.version()) < MIN_ST_VERSION: + raise RuntimeError('DevSkim requires Sublime Text 3 v%d or later.' % + MIN_ST_VERSION) + +# Global Properties + +devskim_event_listener = None + +# Global logger +logger = logging.getLogger(__name__) + +# Cache of user settings (sublime-provided) +user_settings = None + +# Global objects, used for caching +rules = [] + + +# Was the applies_to_ext_mapping already loaded from syntax files? +applies_to_ext_mapping_initialized = False + +# Mapping between symbolic names ("csharp") and what they actually mean. +# This map is updated to include runtime syntax files, so this is just +# a starting point. +applies_to_ext_mapping = { + "csharp": { + "syntax": ["Packages/C#/C#.sublime-syntax"], + "extensions": ["cs"] + }, + "aspnet": { + "syntax": [], + "extensions": ["aspx"] + }, + "python": { + "syntax": ["Packages/Python/Python.sublime-syntax"], + "extensions": ["py"] + }, + "c": { + "syntax": ["Packages/C++/C.sublime-syntax"], + "extensions": ["c", "h"] + }, + "cpp": { + "syntax": ["Packages/C++/C++.sublime-syntax"], + "extensions": ["c", "h", "cpp", "hpp"] + }, + "javascript": { + "syntax": ["Packages/JavaScript/JavaScript.sublime-syntax"], + "extensions": ["js"] + }, + "ruby": { + "syntax": ["Packages/Ruby/Ruby.sublime-syntax"], + "extensions": ["rb", "erb"] + }, + "java": { + "syntax": ["Packages/Java/Java.sublime-syntax"], + "extensions": ["java"] + }, + "php": { + "syntax": ["Packages/PHP/PHP.sublime-syntax"], + "extensions": ["php"] + }, + "objective-c": { + "syntax": ["Packages/Objective-C/Objective-C.sublime-syntax"], + "extensions": ["m", "mm", "h", "c"] + }, + "ios": { + "syntax": ["Packages/Objective-C/Objective-C.sublime-syntax"], + "extensions": ["m", "mm", "h", "c"] + }, + "swift": { + "syntax": ["Packages/Swift/Swift.sublime-syntax"], + "extensions": ["swift"] + }, + "java": { + "syntax": ["Packages/Java/Java.sublime-syntax"], + "extensions": ["java"] + }, + "powershell": { + "syntax": ["Packages/PowerShell/PowerShell.sublime-syntax"], + "extensions": ["ps1"] + }, + "swift": { + "syntax": ["Packages/Swift/Swift.sublime-syntax"], + "extensions": ["swift"] + } +} + +# Currently marked regions +marked_regions = [] + +# Cached stylesheet content +stylesheet_content = "" + +# Cache suppress days +suppress_days = [] + +class DevSkimEventListener(sublime_plugin.EventListener): + """Handles events from Sublime Text.""" + + # Reference to the current view + view = None + + def __init__(self, *args, **kwargs): + """Initialize events listener.""" + super(DevSkimEventListener, self).__init__(*args, **kwargs) + logger.debug("DevSkimEventListener.__init__()") + + def lazy_initialize(self): + """Perform lazy initialization.""" + global rules, user_settings, stylesheet_content, suppress_days + global applies_to_ext_mapping, applies_to_ext_mapping_initialized + + # logger.debug('lazy_initialize') + + if not user_settings: + user_settings = sublime.load_settings('DevSkim.sublime-settings') + if not user_settings: + logger.warning("Unable to load DevSkim settings.") + return + + user_settings.clear_on_change('DevSkim') + user_settings.add_on_change('DevSkim', self.lazy_initialize) + + if not rules or len(rules) == 0: + self.load_rules() + + if not applies_to_ext_mapping_initialized: + self.load_syntax_mapping() + applies_to_ext_mapping_initialized = True + + if not stylesheet_content: + if not user_settings: + logger.warning("Unable to load DevSkim settings.") + return + css_file = user_settings.get('style', 'css/dark.css') + stylesheet_content = sublime.load_resource('Packages/DevSkim/%s' % css_file) + stylesheet_content = stylesheet_content.replace('\r\n', '\n') + logger.debug("Stylesheet: [%s]", stylesheet_content) + + if not suppress_days: + if not user_settings: + logger.warning("Unable to load DevSkim settings.") + return + suppress_days = user_settings.get('suppress_days', [90, 365, -1]) + logger.debug('Suppress Days: %s', suppress_days) + + # Initialize the logger + if len(logger.handlers) == 0: + root_logger = logging.getLogger() + console = logging.StreamHandler() + console.setFormatter(logging.Formatter(LOG_FORMAT)) + + if user_settings.get('debug', False): + print("set to debug") + root_logger.setLevel(logging.DEBUG) + else: + root_logger.setLevel(logging.WARNING) + + logger.handlers = [] + logger.addHandler(console) + + def clear_regions(self, view): + """Clear all regions.""" + global marked_regions, finding_list + + logger.debug("clear_regions()") + + if view: + view.erase_regions("devskim-marks") + marked_regions = [] + finding_list = [] + + def on_selection_modified(self, view): + """Handle selection change events.""" + self.lazy_initialize() + + # logger.debug("on_selection_modified()") + + global marked_regions, finding_list + + try: + # Get the current region (cursor start) + cur_region = view.sel()[0] + + for region in marked_regions: + if region.contains(cur_region): + for finding in finding_list: + if finding.get("match_region") != region: + continue + rule = finding.get('rule') + view.set_status("DevSkim", "DevSkim: %s" % rule.get("name")) + return + + # We're not in a marked region, clear the status bar + view.erase_status("DevSkim") + + except Exception as msg: + logger.warning("Error in on_selection_modified: %s", msg) + + def on_navigate(self, command): + """Handle navigation events.""" + global finding_list, rules + self.lazy_initialize() + + logger.debug("on_navigate(%s)", command) + + if not command: + return + + command = command.strip() + + # Open a regular URL in the user's web browser + if re.match("^https?://", command, re.IGNORECASE): + webbrowser.open_new(command) + + # Special commands, intercept and perform the fix + if command.startswith('#fixit'): + rule_id, fixid = command.split(',')[1:] + fixid = int(fixid) + + for finding in finding_list: + rule = finding.get('rule') + + if rule.get('id') == rule_id: + + region_start = finding.get("match_region").begin() + contents = self.view.substr(self.view.line(region_start)) + + fixit = rule.get('fix_it') + if not fixit or fixid >= len(fixit): + continue + + fixit = fixit[fixid] + if fixit['type'] == 'regex_substitute': + search = fixit['search'] + replace = fixit['replace'] + result = re.sub(search, replace, contents) + logger.debug("Result of search/replace was [%s]", result) + self.view.run_command('replace_text', { + 'a': self.view.line(region_start).a, + 'b': self.view.line(region_start).b, + 'result': result + }) + + self.view.hide_popup() + + # Only fix once + break + + elif command.startswith('#add-suppression'): + rule_id, region_start, suppress_day = command.split(',')[1:] + cur_line = self.view.line(int(region_start)) + + # Ignore suppression for this many days from today + try: + suppress_day = int(suppress_day) + except Exception as msg: + suppress_day = -1 + + if suppress_day == -1: + comment = " DevSkim: ignore %s " % rule_id + else: + until_day = datetime.date.today() + datetime.timedelta(days=suppress_day) + comment = " DevSkim: ignore %s until %s " % (rule_id, until_day.strftime("%Y-%m-%d")) + + # Add the pragma/comment at the end of the current line + self.view.run_command('insert_text', { + 'a': cur_line.b, + 'result': comment + }) + + # Now highlight that new section + prev_sel = self.view.sel() + self.view.sel().clear() + self.view.sel().add(sublime.Region(cur_line.b + 1, cur_line.b + len(comment))) + + # Now make it a block comment + self.view.run_command('toggle_comment', {'block': True}) + self.view.sel().clear() + self.view.sel().add_all(prev_sel) + + self.view.hide_popup() + + else: + logger.debug("Invalid command: %s", command) + + def on_modified(self, view): + """Handle immedate analysis (on keypress).""" + global user_settings + self.lazy_initialize() + + if user_settings.get('show_highlights_on_modified', False): + try: + self.analyze_current_view(view, show_popup=False, single_line=True) + except Exception as msg: + logger.warning("Error analyzing current view: %s", msg) + + def on_load_async(self, view): + """Handle asynchronous loading event.""" + global user_settings + self.lazy_initialize() + + if user_settings.get('show_highlights_on_load', False): + try: + self.analyze_current_view(view, show_popup=False) + except Exception as msg: + logger.warning("Error analyzing current view: %s", msg) + + def on_post_save_async(self, view): + """Handle post-save events.""" + global user_settings + self.lazy_initialize() + + logger.debug("on_post_save_async()") + + if user_settings.get('show_findings_on_save', True): + try: + self.analyze_current_view(view) + except Exception as msg: + logger.warning("Error analyzing current view: %s", msg) + + def analyze_current_view(self, view, show_popup=True, single_line=False): + """Kick off the analysis.""" + global marked_regions, finding_list, user_settings + + if view is None or view.window() is None: + # Early abort if we don't have a View and Window + return + + window = view.window() + + self.lazy_initialize() + + logger.debug("analyze_current_view()") + + # Time the execution of this function + start_time = time.clock() + + self.view = view + + # Check for files too large to scan + max_size = user_settings.get('max_size', 524288) + if 0 < max_size < view.size(): + logger.debug("File was too large to scan (%d bytes)", view.size()) + return + + window = self.view.window() + if window is None: + return + + extension = window.extract_variables().get('file_extension', '') + + show_severity = user_settings.get('show_severity', SEVERITY_LIST) + + # File syntax type + syntax = view.settings().get('syntax') + + # Reset the UI (except if analyzing only a single line) + if not single_line: + self.clear_regions(view) + finding_list = [] + + # Grab the full file as a region + full_region = sublime.Region(0, view.size()) + + # Reset the marked regions for this view + if not single_line: + marked_regions = [] + + if single_line: + file_contents = view.substr(view.line(view.sel()[0])) + logger.debug("Single line: [%s]", file_contents) + offset = view.line(view.sel()[0]).begin() + else: + # Send the entire file over to DevSkim.execute + file_contents = view.substr(full_region) + logger.debug("File contents, size [%d]", len(file_contents)) + offset = 0 + + result_list = [] + try: + _v = window.extract_variables() + filename = _v.get('file', '').replace('\\', '/') + force_analyze = (extension == 'test' and + '/DevSkim/' in filename and + '/tests/' in filename) + result_list = self.execute(file_contents, extension, syntax, show_severity, force_analyze, offset) + logger.debug("DevSkim retured: [%s]", result_list) + except Exception as ex: + logger.warning("Error executing DevSkim: [%s]", ex) + traceback.print_exc() + + # rule['overrides'] logic + overrides_list = [] + + for result in result_list: + if 'overrides' in result['rule']: + overrides_list += result['rule']['overrides'] + + original_result_list_count = len(result_list) + for rule_id in overrides_list: + # Remove all results that match a id in the overrides_list + result_list = list(filter(lambda r: r['rule']['id'] != rule_id, + result_list)) + + if original_result_list_count > len(result_list): + logger.debug("Reduced result list from [%d] to [%d] by overriding rules", + (original_result_list_count, len(result_list))) + + for result in result_list: + # Narrow down to just the matching part of the line + scope_name = view.scope_name(result.get('match_region').begin()) + + scope_list = ["%s." % s for s in result.get('scope_list')] + logger.debug("Current Scope: [%s], Applies to: %s" % (scope_name, scope_list)) + + # Don't actually include if we're in a comment, or a quoted string, etc. + if any([x in scope_name for x in scope_list]) or len(scope_list) == 0: + marked_regions.append(result.get('match_region')) + finding_list.append(result) + + logger.debug("Set marked regions to: %s" % marked_regions) + + # Add a region (squiggly underline) + view.add_regions("devskim-marks", + marked_regions, + "string", + user_settings.get('gutter_mark', 'dot'), + flags=MARK_FLAGS) + + shown_finding_list = [] + + # Sort the findings + sort_by = user_settings.get('sort_results_by', 'line_number') + if sort_by == 'severity': + finding_list.sort(key=lambda s: SEVERITY_LIST.index(s.get('rule').get('severity'))) + elif sort_by == 'line_number': + finding_list.sort(key=lambda s: view.rowcol(s.get('match_region').begin())[0]) + + for finding in finding_list: + rule = finding.get('rule') + region_start = finding.get("match_region").begin() + region = view.rowcol(region_start) + severity = rule.get('severity', 'informational') + severity = self.severity_abbreviation(severity).upper() + + shown_finding_list.append([rule.get("name"), "%d: [%s] %s" % + (region[0] + 1, severity, + view.substr(view.line(region_start)).strip())]) + + if show_popup: + window.show_quick_panel(shown_finding_list, self.on_selected_result) + + end_time = time.clock() + logger.debug("Elapsed time: %f" % (end_time - start_time)) + + def on_selected_result(self, index): + """Handle when the user clicks on a finding from the popup menu.""" + global finding_list, stylesheet_content, user_settings + self.lazy_initialize() + + if index == -1: + return + + chosen_item = finding_list[index] + target_region = chosen_item.get('match_region') + + self.view.sel().clear() + self.view.sel().add(target_region) + self.view.show(target_region) + + rule = chosen_item['rule'] + + # Create a guidance popup + guidance = [''] + + if stylesheet_content: + guidance.append('' % stylesheet_content) + + guidance.append("

%s

" % rule.get('name', 'Missing rule name')) + guidance.append('

%s

' % rule.get('description', 'Missing rule description')) + + if rule.get('replacement'): + guidance.append('

%s

' % rule['replacement']) + + if rule.get('fix_it'): + guidance.append('

Options:

') + guidance.append("") + + # Supression links + this_suppression_links = [] + all_suppression_links = [] + for suppress_day in suppress_days: + suppress_day_str = "%d days" % suppress_day if suppress_day != -1 else "permanently" + this_suppression_links.append('[ %s ] ' % + (rule.get('id'), target_region.a, suppress_day, suppress_day_str)) + all_suppression_links.append('[ %s ] ' % + (target_region.a, suppress_day, suppress_day_str)) + + guidance.append("') + + if rule.get('rule_info'): + guidance.append('

Learn More...

' % rule.get('rule_info')) + elif user_settings.get('debug', False): + guidance.append('

Rule: %s

' % rule.get('id')) + + guidance.append('') + + sublime.set_timeout_async(lambda: self.ds_show_popup(''.join(guidance), + location=target_region.end(), + max_width=860, + max_height=560, + on_navigate=self.on_navigate, + flags=sublime.HTML), 0) + + def load_rules(self, force_reload=False): + """Reload ruleset from the JSON configuration files.""" + global rules, user_settings + + if not force_reload and len(rules) > 0: + logger.debug("Rules already loaded, no need to reload.") + return False + + logger.debug('DevSkimEngine.load_rules()') + + if not user_settings: + logger.warning("Settings not found, cannot load rules.") + return False + + json_filenames = sublime.find_resources("*.json") + rule_filenames = [] + for filename in json_filenames: + for _dir in RULE_DIRECTORY: + if 'DevSkim/rules/%s' % _dir in filename: + rule_filenames.append(filename) + + # Remove duplicates + rule_filenames = list(set(rule_filenames)) + logger.debug("Loaded %d rule files" % len(rule_filenames)) + + # We load rules from each rule file here. + rules = [] + for rule_filename in rule_filenames: + try: + rules += json.loads(sublime.load_resource(rule_filename)) + except Exception as msg: + logger.warning("Error loading [%s]: %s" % (rule_filename, msg)) + if user_settings.get('debug', False): + sublime.error_message("Error loading [%s]" % rule_filename) + + # Now we load custom rules on top of this. + try: + for rule_filename in user_settings.get('custom_rules', []): + try: + env_variables = sublime.active_window().extract_variables() + rule_filename = sublime.expand_variables(rule_filename, + env_variables) + + with open(rule_filename, encoding='utf-8') as crf: + custom_rule = json.loads(crf.read()) + rules += custom_rule + except Exception as msg: + logger.warning("Error opening [%s]: %s" % + (rule_filename, msg)) + except Exception as msg: + logger.warning("Error opening custom rules: %s" % msg) + + if not user_settings: + logger.warning("Settings not found, cannot load rules.") + return False + + for rule_id in user_settings.get('suppress_rules', []): + try: + rules = filter(lambda s: s.get('id', '') != rule_id, rules) + except Exception as msg: + logger.warning("Error suppressing rules for %s: %s" % + (rule_id, msg)) + + # Only include active rules + rules = list(filter(lambda x: x.get('active', True), rules)) + + # Filter by tags, if specified, convert all to lowercase + show_only_tags = set([k.lower().strip() + for k in user_settings.get('show_only_tags', [])]) + if show_only_tags: + def filter_func(x): + return set([k.lower().strip() + for k in x.get('tags', [])]) & show_only_tags + + rules = list(filter(filter_func, rules)) + + logger.debug("Loaded %d rules" % len(rules)) + + # for rule in rules: + # for pattern in rule.get('patterns'): + # print("%s\t%s\t%s" % (pattern.get('pattern'), rule.get('severity'), rule.get('name'))) + + return True + + def load_syntax_mapping(self): + """Load syntax content from various installed packages.""" + global applies_to_ext_mapping + + logger.debug('DevSkimEngine.load_syntax_mapping()') + + for k, v in applies_to_ext_mapping.items(): + applies_to_ext_mapping[k]['syntax'] = \ + set(applies_to_ext_mapping[k]['syntax']) + applies_to_ext_mapping[k]['extensions'] = \ + set(applies_to_ext_mapping[k]['extensions']) + + # Iterate through all syntax files + for filename in sublime.find_resources("*.sublime-syntax"): + # Load the contents + syntax_file = sublime.load_resource(filename) + + applies_to_name = None + for k, v in applies_to_ext_mapping.items(): + for syntax in v.get('syntax', []): + if syntax == filename: + applies_to_name = k + break + + if not applies_to_name: + continue # We need to have these defined first. + + # Look for all extensions + in_file_extensions = False + + for line in syntax_file.splitlines(): + # Clean off wittepsace + line = line.strip() + + # Are we entering? + if line == 'file_extensions:': + in_file_extensions = True + continue + + # Are we in the file extension section? + if in_file_extensions: + if line.startswith('- '): + # Add the extension to the mapping + extension = line.replace("- ", "").strip() + applies_to_ext_mapping[applies_to_name]['extensions'].add(extension) + else: + in_file_extensions = False + break + + def execute(self, file_contents, extension=None, syntax=None, + severities=None, force_analyze=False, offset=0): + """Execute all of the rules against a given string of text.""" + global rules, applies_to_ext_mapping + + logger.debug("execute([len=%d], [%s], [%s], [%s], [%d]" % + (len(file_contents), extension, syntax, force_analyze, offset)) + + if not file_contents: + return [] + + syntax_types = set([]) # Example: ["csharp"], from the file itself + + # TODO Cache this elsewhere, silly to do every time, I think. + for k, v in applies_to_ext_mapping.items(): + if (v.get('syntax', None) == syntax or + extension in v.get('extensions', [])): + syntax_types.add(k) + result_list = [] + + for rule in rules: + + # Don't even scan for rules that we don't care about + if not force_analyze and rule.get('severity', 'critical') not in severities: + logger.debug("Ignoring rule [%s] due to severity." % rule.get('id', '')) + continue + + # No syntax means "match any syntax" + rule_applies_to = rule.get('applies_to', []) + if (force_analyze or + not rule_applies_to or + set(rule_applies_to) & set(syntax_types) or + '.%s' % extension in rule_applies_to): + + for pattern_dict in rule['patterns']: + # Secondary applicability + pattern_applies_to = set(pattern_dict.get('applies_to', [])) + if (pattern_applies_to and + not force_analyze and + not pattern_applies_to & set(syntax_types) and + not '.%s' % extension in pattern_applies_to): + logger.debug("Ignoring rule [%s], applicability check." % rule.get('id', '')) + continue + + pattern_str = pattern_dict.get('pattern') + + start = end = -1 + + orig_pattern_str = pattern_str + + if pattern_dict.get('type') == 'substring': + pattern_str = re.escape(pattern_str) + elif pattern_dict.get('type') == 'string': + pattern_str = r'\b%s\b' % re.escape(pattern_str) + elif pattern_dict.get('type') == 'regex': + pass + elif pattern_dict.get('type') == 'regex_word': + pattern_str = r'\b%s\b' % pattern_str + else: + logger.warning("Invalid pattern type [%s] found." % + pattern_dict.get('type')) + continue + + scope_list = pattern_dict.get('subtype', []) + + modifiers = pattern_dict.get('modifiers', []) + flags = 0 + if modifiers: + modifiers = map(lambda s: s.lower(), modifiers) + + # The rule here is that if a modifier is passed, + # then that's the modifier used. Otherwise, we do + # IGNORECASE | MULTILINE. + if 'dotall' in modifiers: + flags |= re.DOTALL + if 'multiline' in modifiers: + flags |= re.MULTILINE + if 'ignorecase' in modifiers: + flags |= re.IGNORECASE + else: + # Default + flags = re.IGNORECASE | re.MULTILINE + + for match in re.finditer(pattern_str, file_contents, flags): + + if not match: + logger.debug("re.finditer([%s], [%s]) => [-]" % + (pattern_str, len(file_contents))) + continue # Does this ever happen? + + logger.debug("re.finditer([%s], [%s]) => [%d, %d]" % + (pattern_str, len(file_contents), + match.start(), match.end())) + + start = match.start() + end = match.end() + + # Check for per-row suppressions + row_number = self.view.rowcol(start) + line_list = [ + self.view.substr(self.view.line(start)) + ] + if row_number[0] > 0: # Special case, ignore + prev_line = self.view.text_point(row_number[0] - 1, 0) + line_list.append(self.view.substr(self.view.line(prev_line))) + + if self.is_suppressed(rule, line_list): + continue # Don't add the result to the list + + result_list.append({ + 'rule': rule, + 'match_content': match.group(), + 'match_region': sublime.Region(start + offset, end + offset), + 'match_start': start + offset, + 'match_end': end + offset, + 'pattern': orig_pattern_str, + 'scope_list': scope_list + }) + + return result_list + + def severity_abbreviation(self, severity): + """Convert a severity name into an abbreviation.""" + if severity is None: + return "" + severity = severity.strip().lower() + + if severity == "critical": + return "crit" + elif severity == "important": + return "imp." + elif severity == "moderate": + return "mod." + elif severity == "low": + return "low" + elif severity == "defense-in-depth": + return "did." + elif severity == "informational": + return "info" + return "" + + def is_suppressed(self, rule, lines): + """Should the result be suppressed based on the given rule and line content.""" + global user_settings + # self.lazy_initialize() + + if not rule or not lines: + return False + + # Are suppression rules enabled at all? + if not user_settings.get('allow_suppress_specific_rules') and not user_settings.get('allow_suppress_all_rules'): + logger.debug('Suppression disabled via config, nothing to do.') + return False + + for line in lines: + line = line.lower() + if 'devskim:' not in line: + continue + + if user_settings.get('allow_suppress_specific_rules'): + for match in re.finditer(r'ignore ([^\s]+)\s+until (\d{4}-\d{2}-\d{2})', line): + if match.group(1) in ['all', rule.get('id', 'all').lower()]: + suppress_until = match.group(2) + try: + suppress_until = datetime.datetime.strptime(suppress_until, '%Y-%m-%d') + if datetime.date.today() < suppress_until.date(): + logger.debug('Ignoring rule [%s] due to limited suppression.', rule.get('id')) + return True + except Exception as msg: + logger.debug("Error parsing suppression date: %s", msg) + + if not user_settings.get('allow_suppress_all_rules'): + for match in re.finditer(r'ignore ([^\s]+)\s*(?!\s+until \d{4}-\d{2}-\d{2})', line): + if match.group(1) in ['all', rule.get('id', 'all').lower()]: + logger.debug('Ignoring rule [%s] due to unlimited suppression.', rule.get('id')) + return True + + return False + + def ds_show_popup(self, content, flags, location, max_width, max_height, + on_navigate=None, on_hide=None, repeat_duration_ms=50): + """Delay-load a popup to give the UI time to get to the scrolled position.""" + if self.view.is_popup_visible(): + return # OK, a popup is already being shown + + # Try to show the popup + self.view.show_popup(content=content, + flags=flags, + location=location, + max_width=max_width, + max_height=max_height, + on_navigate=on_navigate, + on_hide=on_hide) + + # Retry in case we're scrolling + sublime.set_timeout_async(lambda: self.ds_show_popup(content=content, + flags=flags, + location=location, + max_width=max_width, + max_height=max_height, + on_navigate=on_navigate, + on_hide=on_hide), repeat_duration_ms) + + +class ReplaceTextCommand(sublime_plugin.TextCommand): + """Simple function to route text changes to view.""" + + def run(self, edit, a, b, result): + """Replace given text for a region in a view.""" + logger.debug("Replacing [%s] into region (%d, %d)" % (result, a, b)) + self.view.replace(edit, sublime.Region(a, b), result) + + +class InsertTextCommand(sublime_plugin.TextCommand): + """Simple function to route text inserts to view.""" + + def run(self, edit, a, result): + """Insert given text for a region in a view.""" + self.view.insert(edit, a, result) + + +class DevSkimAnalyzeCommand(sublime_plugin.TextCommand): + """Perform an ad-hoc analysis of the open file.""" + + def run(self, text): + """Execute the analysis.""" + global devskim_event_listener + if not devskim_event_listener: + devskim_event_listener = DevSkimEventListener() + try: + devskim_event_listener.analyze_current_view(self.view) + except Exception as msg: + logger.warning("Error analyzing current view: %s" % msg) + + +class DevSkimReloadRulesCommand(sublime_plugin.TextCommand): + """Mark the DevSkim rules to be reloaded next time they're needed.""" + + def run(self, text): + """Execute the analysis.""" + global rules, stylesheet_content + rules = [] + stylesheet_content = "" + + +def plugin_loaded(): + """Handle the plugin_loaded event from ST3.""" + logger.info('DevSkim plugin_loaded(), Sublime Text v%s' % sublime.version()) + + +def plugin_unloaded(): + """Handle the plugin_unloaded event from ST3.""" + logger.info("DevSkim plugin_unloaded()") diff --git a/DevSkim.sublime-commands b/DevSkim.sublime-commands new file mode 100644 index 0000000..20052d6 --- /dev/null +++ b/DevSkim.sublime-commands @@ -0,0 +1,10 @@ +[ + { + "caption": "DevSkim: Analyze File", + "command": "dev_skim_analyze" + }, + { + "caption": "DevSkim: Reload Configuration", + "command": "dev_skim_reload_rules" + } +] \ No newline at end of file diff --git a/DevSkim.sublime-project b/DevSkim.sublime-project new file mode 100644 index 0000000..2c63c08 --- /dev/null +++ b/DevSkim.sublime-project @@ -0,0 +1,2 @@ +{ +} diff --git a/DevSkim.sublime-settings b/DevSkim.sublime-settings new file mode 100644 index 0000000..4b1cdc7 --- /dev/null +++ b/DevSkim.sublime-settings @@ -0,0 +1,53 @@ +// Don't change any setting on this file. If you want to override the default +// behaviour, change the settings values on your +// Packages/User/DevSkim.sublime-settings file. +{ + + /* Enable verbose output to console */ + "debug": false, + + /* Don't analyze files greater than 512kb - set to -1 to disable */ + "max_size": 524288, + + /* Show all severities */ + "show_severity": ["critical", "important", "moderate", "low", "defense-in-depth", "informational"], + + /* Mark for the page gutter -- available options dot, circle, cross, or "" */ + "gutter_mark": "dot", + + /* Stylesheet for popup, from package directory */ + "style": "css/dark.css", + + /* Show only rules with the given tags, empty means "show all" */ + "show_only_tags": [], + + /* Sort results by either "severity" or "line_number" */ + "sort_results_by": "line_number", + + /* Run analysis as soon as a file is opened. */ + "show_highlights_on_load": true, + + /* Run analysis when the file is saved. */ + "show_findings_on_save": true, + + /* Run analysis whenever a file is changed. */ + "show_highlights_on_modified": true, + + /* Enable per-rule suppressions */ + "allow_suppress_specific_rules": true, + + /* Enable global suppressions */ + "allow_suppress_all_rules": true, + + /* Allow suppression for specific # of days, or -1 for "permanent" */ + "suppress_days": [90, 365, -1], + + /* List of rules files to also include. */ + "custom_rules": [ + ], + + /* Globally suppress these rules (list of rule IDs, like "DS123456") */ + "suppress_rules": [ + ] + +} diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..61bf7e0 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,17 @@ +Copyright (c) 2016 Microsoft Corporation + +All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, +modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT +OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Main.sublime-menu b/Main.sublime-menu new file mode 100644 index 0000000..966e53f --- /dev/null +++ b/Main.sublime-menu @@ -0,0 +1,35 @@ +[ + { + "caption": "Preferences", + "mnemonic": "n", + "id": "preferences", + "children": [ + { + "caption": "Package Settings", + "mnemonic": "P", + "id": "package-settings", + "children": [ + { + "caption": "DevSkim", + "children": [ + { + "command": "open_file", + "args": { + "file": "${packages}/DevSkim/DevSkim.sublime-settings" + }, + "caption": "Settings – Default" + }, + { + "command": "open_file", + "args": { + "file": "${packages}/User/DevSkim.sublime-settings" + }, + "caption": "Settings – User" + } + ] + } + ] + } + ] + } +] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..69c6570 --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +DevSkim Plugin for Sublime Text +=============================== + +The plugin implements a security linter within the Sublime Text editor, leveraging the rules from the [DevSkim-Rules](https://github.com/Microsoft/DevSkim-Rules) repo. It helps software engineers to write secure code by flagging potentially dangerous calls, and gives in-context advice for remediation. + +Requirements +-------------- + +The plugin requires Sublime Text 3 (build >= 3114), and will function on Windows, Linux, and MacOS. + +Installation +------------ +If using [Package Control](https://packagecontrol.io/) for Sublime Text, simply install the `DevSkim` package. + +Alternatively, you can clone the plugin and rules repos directly into your Sublime plugin folder. For example, for Sublime Text 3 on a Mac this would look something like: + +``` +cd ~/"Library/Application Support/Sublime Text 3/Packages" +git clone --depth 1 https://github.com/Microsoft/DevSkim-Sublime-Plugin.git DevSkim +cd DevSkim +git clone --depth 1 https://github.com/Microsoft/DevSkim-Rules.git rules +``` +And on Windows: +``` +cd "%APPDATA%\Sublime Text 3\Packages" +git clone --depth 1 https://github.com/Microsoft/DevSkim-Sublime-Plugin.git DevSkim +cd DevSkim +git clone --depth 1 https://github.com/Microsoft/DevSkim-Rules.git rules +``` + +(`--depth 1` downloads only the current version to reduce the clone size.) + +Note if you are using the portable version of Sublime Text, the location will be different. (See http://docs.sublimetext.info/en/latest/basic_concepts.html#the-data-directory for more info). + +**IMPORTANT** If you already have a package called `DevSkim` installed, either remove this first, or clone this repo to a different folder, else module name resolution can break the plugin. + +Platform support +---------------- +#### Operating System: + +The plugin has identical behavior across Windows, Mac, and Linux. + +#### Sublime Text Version: + +The plugin requires [Sublime Text 3](http://www.sublimetext.com/3) build >= 3114. + +Features +-------- +The below features are available via the keyboard shortcuts shown, or via the Command Palette (^ means the `ctrl` or `cmd` keys): + +| Feature | Shortcut | +|-----------------------|-----------------| +| Run DevSkim | `^D` | + +The + +The "format on key" feature is on by default, which formats the current line after typing `;`, `}` or `enter`. +To disable it, go to `Preferences` -> `Package Settings` -> `DevSkim` -> `Plugin Settings - User`, and add +`"typescript_auto_format": false` to the json file. + +Rules System +------------ + +The plugin supports both built-in and custom rules: + +#### Built-In Rules + +Built-in rules come from the [DevSkim-Rules](https://github.com/Microsoft/DevSkim-Rules.git) repo, and should be stored +in the `rules` directory within the DevSkim directory. + +Rules are organized by subdirectory and file, but are flattened internally when loaded. + +Each rule contains a set of patterns (strings and regular expressions) to match, a list of file types to +apply the rule to, and, optionally, a list of possible code fixes. An example rule is shown below: + +``` +[ + { + "id": "DS126858", + "name": "Weak/Broken Hash Algorithm", + "active": true, + "tags": [ + "Cryptography.BannedHashAlgorithm" + ], + "severity": "critical", + "description": "A weak or broken hash algorithm was detected.", + "replacement": "Consider switching to use SHA-256 or SHA-512 instead.", + "rule_info": "https://github.com/microsoft/devskim/guidance/DS126858.md", + "applies_to": [ + "$PHP" + ], + "patterns": [ + { + "pattern": "md5(", + "type": "string" + }, + ], + "fix_it": [ + { + "type": "regex_substitute", + "name": "Change to SHA-256", + "search": "\\bmd5\\(([^\\)]+\\)", + "replace": "hash('sha256', \\1)" + } + ] + } +] +``` + +Screenshots +------ + +TODO + +Reporting Issues +------- +Please see [CONTRIBUTING](https://github.com/Microsoft/DevSkim-Sublime-Plugin/blob/master/CONTRIBUTING.md) for information on reporting issues and contributing code. + +Tips and Known Issues +---- +See tips and known issues in the [wiki page](https://github.com/Microsoft/DevSkim-Sublime-Plugin/wiki/Tips-and-Known-Issues). diff --git a/css/dark.css b/css/dark.css new file mode 100644 index 0000000..29550d0 --- /dev/null +++ b/css/dark.css @@ -0,0 +1,28 @@ +html { + background-color: #002b36; + color: #CCCCCC; +} +body { +} +a { + color: #268bd2; +} +b { + color: #b58900; +} +h1 { + color: #2aa198; +} +h2 { + color: #2aa198; +} +h3 { +} +h4 { +} +h5 { + color: #2aa198; +} +p { + margin-right: 10px; +} \ No newline at end of file diff --git a/css/default.css b/css/default.css new file mode 100644 index 0000000..9fade1b --- /dev/null +++ b/css/default.css @@ -0,0 +1,29 @@ +html { + background-color: #ffffff; + color: #000000; +} +body { + font-size: 14px; +} +a { + color: #268bd2; +} +b { + color: #b58900; +} +h1 { + color: #2aa198; + font-size: 16px; +} +h2 { + color: #2aa198; + font-size: 14px; +} +h5 { + color: #2aa198; + font-size: 10px; +} +p { + margin-left: 10px; + margin-right: 10px; +} \ No newline at end of file diff --git a/messages.json b/messages.json new file mode 100644 index 0000000..502d0cd --- /dev/null +++ b/messages.json @@ -0,0 +1,4 @@ +{ + "install": "messages/install.txt", + "1.0.0": "messages/1.0.0.txt" +} \ No newline at end of file diff --git a/messages/1.0.0.txt b/messages/1.0.0.txt new file mode 100644 index 0000000..fd4896f --- /dev/null +++ b/messages/1.0.0.txt @@ -0,0 +1,5 @@ +DevSkim 1.0.0 +============== + +2016-09-21 - Initial public release of the DevSkim Sublime Text plugin. + diff --git a/messages/install.txt b/messages/install.txt new file mode 100644 index 0000000..04680ff --- /dev/null +++ b/messages/install.txt @@ -0,0 +1,22 @@ +Welcome to DevSkim +===================== + +DevSkim is a source code linter, specifically designed to flag high-risk +security vulnerabilities, provide actionable recommendations (including +"auto-fix" functionality) and time-limited suppressions. + +DevSkim comes with a pre-defined set of rules, which can be extended by +users or organizations. + + +Issues, Questions or Bugs? +-------------------------- + +For issues with DevSkim, please open an issue for the plugin project: + + https://github.com/Microsoft/DevSkim-SublimeText/issues + +For issues with rules, please open an issue for the rules project: + + https://github.com/Microsoft/DevSkim-Rules/issues +