diff --git a/scripts/safe_template_linter.py b/scripts/safe_template_linter.py index d85433baff..23cf001a1d 100755 --- a/scripts/safe_template_linter.py +++ b/scripts/safe_template_linter.py @@ -40,7 +40,7 @@ def _is_skip_dir(skip_dirs, directory): return False -def _load_file(self, file_full_path): +def _load_file(file_full_path): """ Loads a file into a string. @@ -56,6 +56,77 @@ def _load_file(self, file_full_path): return file_contents.decode(encoding='utf-8') +def _find_closing_char_index(start_delim, open_char, close_char, template, start_index, num_open_chars=0, strings=None): + """ + Finds the index of the closing char that matches the opening char. + + For example, this could be used to find the end of a Mako expression, + where the open and close characters would be '{' and '}'. + + Arguments: + start_delim: If provided (e.g. '${' for Mako expressions), the + closing character must be found before the next start_delim. + open_char: The opening character to be matched (e.g '{') + close_char: The closing character to be matched (e.g '}') + template: The template to be searched. + start_index: The start index of the last open char. + num_open_chars: The current number of open chars. + strings: A list of ParseStrings already parsed + + Returns: + A dict containing the following, or None if unparseable: + close_char_index: The index of the closing character + strings: a list of ParseStrings + + """ + strings = [] if strings is None else strings + close_char_index = template.find(close_char, start_index) + if close_char_index < 0: + # if we can't find an end_char, let's just quit + return None + open_char_index = template.find(open_char, start_index, close_char_index) + parse_string = ParseString(template, start_index, close_char_index) + + valid_index_list = [close_char_index] + if 0 <= open_char_index: + valid_index_list.append(open_char_index) + if parse_string.start_index is not None: + valid_index_list.append(parse_string.start_index) + min_valid_index = min(valid_index_list) + + if parse_string.start_index == min_valid_index: + strings.append(parse_string) + if parse_string.end_index is None: + return None + else: + return _find_closing_char_index( + start_delim, open_char, close_char, template, start_index=parse_string.end_index, + num_open_chars=num_open_chars, strings=strings + ) + + if open_char_index == min_valid_index: + if start_delim is not None: + # if we find another starting delim, consider this unparseable + start_delim_index = template.find(start_delim, start_index, close_char_index) + if 0 <= start_delim_index < open_char_index: + return None + return _find_closing_char_index( + start_delim, open_char, close_char, template, start_index=open_char_index + 1, + num_open_chars=num_open_chars + 1, strings=strings + ) + + if num_open_chars == 0: + return { + 'close_char_index': close_char_index, + 'strings': strings, + } + else: + return _find_closing_char_index( + start_delim, open_char, close_char, template, start_index=close_char_index + 1, + num_open_chars=num_open_chars - 1, strings=strings + ) + + class StringLines(object): """ StringLines provides utility methods to work with a string in terms of @@ -72,7 +143,10 @@ class StringLines(object): """ self._string = string - self._line_breaks = self._process_line_breaks(string) + self._line_start_indexes = self._process_line_breaks(string) + # this is an exclusive index used in the case that the template doesn't + # end with a new line + self.eof_index = len(string) def _process_line_breaks(self, string): """ @@ -83,19 +157,18 @@ class StringLines(object): string: The string in which to find line breaks. Returns: - A list of indices into the string at which each line break can be - found. + A list of indices into the string at which each line begins. """ - line_breaks = [0] + line_start_indexes = [0] index = 0 while True: index = string.find('\n', index) if index < 0: break index += 1 - line_breaks.append(index) - return line_breaks + line_start_indexes.append(index) + return line_start_indexes def get_string(self): """ @@ -116,7 +189,7 @@ class StringLines(object): """ current_line_number = 0 - for line_break_index in self._line_breaks: + for line_break_index in self._line_start_indexes: if line_break_index <= index: current_line_number += 1 else: @@ -154,6 +227,20 @@ class StringLines(object): line_number = self.index_to_line_number(index) return self.line_number_to_start_index(line_number) + def index_to_line_end_index(self, index): + """ + Gets the index of the end of the line of the given index. + + Arguments: + index: The index into the original string. + + Returns: + The index of the end of the line of the given index. + + """ + line_number = self.index_to_line_number(index) + return self.line_number_to_end_index(line_number) + def line_number_to_start_index(self, line_number): """ Gets the starting index for the provided line number. @@ -166,7 +253,26 @@ class StringLines(object): The starting index for the provided line number. """ - return self._line_breaks[line_number - 1] + return self._line_start_indexes[line_number - 1] + + def line_number_to_end_index(self, line_number): + """ + Gets the ending index for the provided line number. + + Arguments: + line_number: The line number of the line for which we want to find + the end index. + + Returns: + The ending index for the provided line number. + + """ + if line_number < len(self._line_start_indexes): + return self._line_start_indexes[line_number] + else: + # an exclusive index in the case that the file didn't end with a + # newline. + return self.eof_index def line_number_to_line(self, line_number): """ @@ -179,11 +285,11 @@ class StringLines(object): The line of text designated by the provided line number. """ - start_index = self._line_breaks[line_number - 1] - if len(self._line_breaks) == line_number: + start_index = self._line_start_indexes[line_number - 1] + if len(self._line_start_indexes) == line_number: line = self._string[start_index:] else: - end_index = self._line_breaks[line_number] + end_index = self._line_start_indexes[line_number] line = self._string[start_index:end_index - 1] return line @@ -191,7 +297,7 @@ class StringLines(object): """ Gets the number of lines in the string. """ - return len(self._line_breaks) + return len(self._line_start_indexes) class Rules(Enum): @@ -222,6 +328,14 @@ class Rules(Enum): 'mako-invalid-js-filter', 'The expression is using an invalid filter in a JavaScript context.' ) + mako_js_missing_quotes = ( + 'mako-js-missing-quotes', + 'An expression using js_escaped_string must be wrapped in quotes.' + ) + mako_js_html_string = ( + 'mako-js-html-string', + 'A JavaScript string containing HTML should not have an embedded Mako expression.' + ) mako_deprecated_display_name = ( 'mako-deprecated-display-name', 'Replace deprecated display_name_with_default_escaped with display_name_with_default.' @@ -254,12 +368,95 @@ class Rules(Enum): 'underscore-not-escaped', 'Expressions should be escaped using <%- expression %>.' ) + javascript_jquery_append = ( + 'javascript-jquery-append', + 'Use HtmlUtils.append() or .append(HtmlUtils.xxx().toString()).' + ) + javascript_jquery_prepend = ( + 'javascript-jquery-prepend', + 'Use HtmlUtils.prepend() or .prepend(HtmlUtils.xxx().toString()).' + ) + javascript_jquery_insertion = ( + 'javascript-jquery-insertion', + 'JQuery DOM insertion calls that take content must use HtmlUtils (e.g. $el.after(HtmlUtils.xxx().toString()).' + ) + javascript_jquery_insert_into_target = ( + 'javascript-jquery-insert-into-target', + 'JQuery DOM insertion calls that take a target can only be called from elements (e.g. .$el.appendTo()).' + ) + javascript_jquery_html = ( + 'javascript-jquery-html', + "Use HtmlUtils.setHtml(), .html(HtmlUtils.xxx().toString()), or JQuery's text() function." + ) + javascript_concat_html = ( + 'javascript-concat-html', + 'Use HtmlUtils functions rather than concatenating strings with HTML.' + ) + javascript_escape = ( + 'javascript-escape', + "Avoid calls to escape(), especially in Backbone. Use templates, HtmlUtils, or JQuery's text() function." + ) + javascript_interpolate = ( + 'javascript-interpolate', + 'Use StringUtils.interpolate() or HtmlUtils.interpolateHtml() as appropriate.' + ) def __init__(self, rule_id, rule_summary): self.rule_id = rule_id self.rule_summary = rule_summary +class Expression(object): + """ + Represents an arbitrary expression. + + An expression can be any type of code snippet. It will sometimes have a + starting and ending delimiter, but not always. + + Here are some example expressions:: + + ${x | n, decode.utf8} + <%= x %> + function(x) + "

" + message + "

" + + Other details of note: + - Only a start_index is required for a valid expression. + - If end_index is None, it means we couldn't parse the rest of the + expression. + - All other details of the expression are optional, and are only added if + and when supplied and needed for additional checks. They are not necessary + for the final results output. + + """ + + def __init__(self, start_index, end_index=None, template=None, start_delim="", end_delim="", strings=None): + """ + Init method. + + Arguments: + start_index: the starting index of the expression + end_index: the index immediately following the expression, or None + if the expression was unparseable + template: optional template code in which the expression was found + start_delim: optional starting delimiter of the expression + end_delim: optional ending delimeter of the expression + strings: optional list of ParseStrings + + """ + self.start_index = start_index + self.end_index = end_index + self.start_delim = start_delim + self.end_delim = end_delim + self.strings = strings + if template is not None and self.end_index is not None: + self.expression = template[start_index:end_index] + self.expression_inner = self.expression[len(start_delim):-len(end_delim)].strip() + else: + self.expression = None + self.expression_inner = None + + class RuleViolation(object): """ Base class representing a rule violation which can be used for reporting. @@ -312,6 +509,18 @@ class RuleViolation(object): self.is_disabled = True return + def sort_key(self): + """ + Returns a key that can be sorted on + """ + return 0 + + def first_line(self): + """ + Since a file level rule has no first line, returns empty string. + """ + return '' + def prepare_results(self, full_path, string_lines): """ Preps this instance for results reporting. @@ -349,7 +558,7 @@ class ExpressionRuleViolation(RuleViolation): Arguments: rule: The Rule which was violated. - expression: The expression that was in violation. + expression: The Expression that was in violation. """ super(ExpressionRuleViolation, self).__init__(rule) @@ -400,6 +609,18 @@ class ExpressionRuleViolation(RuleViolation): line_to_check = string_lines.line_number_to_line(self.start_line) self._mark_disabled(line_to_check, scope_start_string=False) + def sort_key(self): + """ + Returns a key that can be sorted on + """ + return (self.start_line, self.start_column) + + def first_line(self): + """ + Returns the initial line of code of the violation. + """ + return self.lines[0] + def prepare_results(self, full_path, string_lines): """ Preps this instance for results reporting. @@ -411,11 +632,11 @@ class ExpressionRuleViolation(RuleViolation): """ self.full_path = full_path - start_index = self.expression['start_index'] + start_index = self.expression.start_index self.start_line = string_lines.index_to_line_number(start_index) self.start_column = string_lines.index_to_column_number(start_index) - end_index = self.expression['end_index'] - if end_index > 0: + end_index = self.expression.end_index + if end_index is not None: self.end_line = string_lines.index_to_line_number(end_index) self.end_column = string_lines.index_to_column_number(end_index) else: @@ -467,17 +688,21 @@ class FileResults(object): self.is_file = os.path.isfile(full_path) self.violations = [] - def prepare_results(self, file_string): + def prepare_results(self, file_string, line_comment_delim=None): """ Prepares the results for output for this file. Arguments: file_string: The string of content for this file. + line_comment_delim: A string representing the start of a line + comment. For example "##" for Mako and "//" for JavaScript. """ string_lines = StringLines(file_string) for violation in self.violations: violation.prepare_results(self.full_path, string_lines) + if line_comment_delim is not None: + self._filter_commented_code(line_comment_delim) def print_results(self, options, out): """ @@ -489,14 +714,49 @@ class FileResults(object): all violations. out: output file + Returns: + The number of violations. When using --quiet, returns number of + files with violations. """ + num_violations = 0 if options['is_quiet']: - print(self.full_path, file=out) + if self.violations is not None and 0 < len(self.violations): + num_violations += 1 + print(self.full_path, file=out) else: + self.violations.sort(key=lambda violation: violation.sort_key()) for violation in self.violations: if not violation.is_disabled: + num_violations += 1 violation.print_results(out) + return num_violations + + def _filter_commented_code(self, line_comment_delim): + """ + Remove any violations that were found in commented out code. + + Arguments: + line_comment_delim: A string representing the start of a line + comment. For example "##" for Mako and "//" for JavaScript. + + """ + self.violations = [v for v in self.violations if not self._is_commented(v, line_comment_delim)] + + def _is_commented(self, violation, line_comment_delim): + """ + Checks if violation line is commented out. + + Arguments: + violation: The violation to check + line_comment_delim: A string representing the start of a line + comment. For example "##" for Mako and "//" for JavaScript. + + Returns: + True if the first line of the violation is actually commented out, + False otherwise. + """ + return violation.first_line().lstrip().startswith(line_comment_delim) class ParseString(object): @@ -504,8 +764,8 @@ class ParseString(object): ParseString is the result of parsing a string out of a template. A ParseString has the following attributes: - start_index: The index of the first quote, or -1 if none found - end_index: The index following the closing quote, or -1 if + start_index: The index of the first quote, or None if none found + end_index: The index following the closing quote, or None if unparseable quote_length: The length of the quote. Could be 3 for a Python triple quote. Or None if none found. @@ -525,12 +785,12 @@ class ParseString(object): end_index: The end index to search before. """ - self.end_index = -1 + self.end_index = None self.quote_length = None self.string = None self.string_inner = None self.start_index = self._find_string_start(template, start_index, end_index) - if 0 <= self.start_index: + if self.start_index is not None: result = self._parse_string(template, self.start_index) if result is not None: self.end_index = result['end_index'] @@ -549,13 +809,13 @@ class ParseString(object): end_index: The end index to search before. Returns: - The start index of the first single or double quote, or -1 if - no quote was found. + The start index of the first single or double quote, or None if no + quote was found. """ quote_regex = re.compile(r"""['"]""") start_match = quote_regex.search(template, start_index, end_index) if start_match is None: - return -1 + return None else: return start_match.start() @@ -603,12 +863,540 @@ class ParseString(object): } +class UnderscoreTemplateLinter(object): + """ + The linter for Underscore.js template files. + """ + + _skip_underscore_dirs = _skip_dirs + ('test',) + + def process_file(self, directory, file_name): + """ + Process file to determine if it is an Underscore template file and + if it is safe. + + Arguments: + directory (string): The directory of the file to be checked + file_name (string): A filename for a potential underscore file + + Returns: + The file results containing any violations. + + """ + full_path = os.path.normpath(directory + '/' + file_name) + results = FileResults(full_path) + + if not self._is_valid_directory(directory): + return results + + if not file_name.lower().endswith('.underscore'): + return results + + return self._load_and_check_underscore_file_is_safe(full_path, results) + + def _is_valid_directory(self, directory): + """ + Determines if the provided directory is a directory that could contain + Underscore.js template files that need to be linted. + + Arguments: + directory: The directory to be linted. + + Returns: + True if this directory should be linted for Underscore.js template + violations and False otherwise. + """ + if _is_skip_dir(self._skip_underscore_dirs, directory): + return False + + return True + + def _load_and_check_underscore_file_is_safe(self, file_full_path, results): + """ + Loads the Underscore.js template file and checks if it is in violation. + + Arguments: + file_full_path: The file to be loaded and linted + + Returns: + The file results containing any violations. + + """ + underscore_template = _load_file(file_full_path) + self.check_underscore_file_is_safe(underscore_template, results) + return results + + def check_underscore_file_is_safe(self, underscore_template, results): + """ + Checks for violations in an Underscore.js template. + + Arguments: + underscore_template: The contents of the Underscore.js template. + results: A file results objects to which violations will be added. + + """ + self._check_underscore_expressions(underscore_template, results) + results.prepare_results(underscore_template) + + def _check_underscore_expressions(self, underscore_template, results): + """ + Searches for Underscore.js expressions that contain violations. + + Arguments: + underscore_template: The contents of the Underscore.js template. + results: A list of results into which violations will be added. + + """ + expressions = self._find_unescaped_expressions(underscore_template) + for expression in expressions: + if not self._is_safe_unescaped_expression(expression): + results.violations.append(ExpressionRuleViolation( + Rules.underscore_not_escaped, expression + )) + + def _is_safe_unescaped_expression(self, expression): + """ + Determines whether an expression is safely escaped, even though it is + using the expression syntax that doesn't itself escape (i.e. <%= ). + + In some cases it is ok to not use the Underscore.js template escape + (i.e. <%- ) because the escaping is happening inside the expression. + + Safe examples:: + + <%= HtmlUtils.ensureHtml(message) %> + <%= _.escape(message) %> + + Arguments: + expression: The Expression being checked. + + Returns: + True if the Expression has been safely escaped, and False otherwise. + + """ + if expression.expression_inner.startswith('HtmlUtils.'): + return True + if expression.expression_inner.startswith('_.escape('): + return True + return False + + def _find_unescaped_expressions(self, underscore_template): + """ + Returns a list of unsafe expressions. + + At this time all expressions that are unescaped are considered unsafe. + + Arguments: + underscore_template: The contents of the Underscore.js template. + + Returns: + A list of Expressions. + """ + unescaped_expression_regex = re.compile("<%=.*?%>", re.DOTALL) + + expressions = [] + for match in unescaped_expression_regex.finditer(underscore_template): + expression = Expression( + match.start(), match.end(), template=underscore_template, start_delim="<%=", end_delim="%>" + ) + expressions.append(expression) + return expressions + + +class JavaScriptLinter(object): + """ + The linter for JavaScript and CoffeeScript files. + """ + + _skip_javascript_dirs = _skip_dirs + ('i18n', 'static/coffee') + _skip_coffeescript_dirs = _skip_dirs + underScoreLinter = UnderscoreTemplateLinter() + + def process_file(self, directory, file_name): + """ + Process file to determine if it is a JavaScript file and + if it is safe. + + Arguments: + directory (string): The directory of the file to be checked + file_name (string): A filename for a potential JavaScript file + + Returns: + The file results containing any violations. + + """ + file_full_path = os.path.normpath(directory + '/' + file_name) + results = FileResults(file_full_path) + + if not results.is_file: + return results + + if file_name.lower().endswith('.js') and not file_name.lower().endswith('.min.js'): + skip_dirs = self._skip_javascript_dirs + elif file_name.lower().endswith('.coffee'): + skip_dirs = self._skip_coffeescript_dirs + else: + return results + + if not self._is_valid_directory(skip_dirs, directory): + return results + + return self._load_and_check_javascript_file_is_safe(file_full_path, results) + + def _is_valid_directory(self, skip_dirs, directory): + """ + Determines if the provided directory is a directory that could contain + a JavaScript file that needs to be linted. + + Arguments: + skip_dirs: The directories to be skipped. + directory: The directory to be linted. + + Returns: + True if this directory should be linted for JavaScript violations + and False otherwise. + """ + if _is_skip_dir(skip_dirs, directory): + return False + + return True + + def _load_and_check_javascript_file_is_safe(self, file_full_path, results): + """ + Loads the JavaScript file and checks if it is in violation. + + Arguments: + file_full_path: The file to be loaded and linted. + + Returns: + The file results containing any violations. + + """ + file_contents = _load_file(file_full_path) + self.check_javascript_file_is_safe(file_contents, results) + return results + + def check_javascript_file_is_safe(self, file_contents, results): + """ + Checks for violations in a JavaScript file. + + Arguments: + file_contents: The contents of the JavaScript file. + results: A file results objects to which violations will be added. + + """ + no_caller_check = None + no_argument_check = None + self._check_jquery_function( + file_contents, "append", Rules.javascript_jquery_append, no_caller_check, + self._is_jquery_argument_safe, results + ) + self._check_jquery_function( + file_contents, "prepend", Rules.javascript_jquery_prepend, no_caller_check, + self._is_jquery_argument_safe, results + ) + self._check_jquery_function( + file_contents, "unwrap|wrap|wrapAll|wrapInner|after|before|replaceAll|replaceWith", + Rules.javascript_jquery_insertion, no_caller_check, self._is_jquery_argument_safe, results + ) + self._check_jquery_function( + file_contents, "appendTo|prependTo|insertAfter|insertBefore", + Rules.javascript_jquery_insert_into_target, self._is_jquery_insert_caller_safe, no_argument_check, results + ) + self._check_jquery_function( + file_contents, "html", Rules.javascript_jquery_html, no_caller_check, + self._is_jquery_html_argument_safe, results + ) + self._check_javascript_interpolate(file_contents, results) + self._check_javascript_escape(file_contents, results) + self._check_concat_with_html(file_contents, results) + self.underScoreLinter.check_underscore_file_is_safe(file_contents, results) + results.prepare_results(file_contents, line_comment_delim='//') + + def _get_expression_for_function(self, file_contents, function_start_match): + """ + Returns an expression that matches the function call opened with + function_start_match. + + Arguments: + file_contents: The contents of the JavaScript file. + function_start_match: A regex match representing the start of the function + call (e.g. ".escape("). + + Returns: + An Expression that best matches the function. + + """ + start_index = function_start_match.start() + inner_start_index = function_start_match.end() + result = _find_closing_char_index( + None, "(", ")", file_contents, start_index=inner_start_index + ) + if result is not None: + end_index = result['close_char_index'] + 1 + expression = Expression( + start_index, end_index, template=file_contents, start_delim=function_start_match.group(), end_delim=")" + ) + else: + expression = Expression(start_index) + return expression + + def _check_javascript_interpolate(self, file_contents, results): + """ + Checks that interpolate() calls are safe. + + Only use of StringUtils.interpolate() or HtmlUtils.interpolateText() + are safe. + + Arguments: + file_contents: The contents of the JavaScript file. + results: A file results objects to which violations will be added. + + """ + # Ignores calls starting with "StringUtils.", because those are safe + regex = re.compile(r"(?'))" + or ".append($('
'))". + - the argument can be a call to HtmlUtils.xxx(html).toString() + + Arguments: + argument: The argument sent to the jQuery function (e.g. + append(argument)). + + Returns: + True if the argument is safe, and False otherwise. + + """ + match_variable_name = re.search("[_$a-zA-Z]+[_$a-zA-Z0-9]*", argument) + if match_variable_name is not None and match_variable_name.group() == argument: + if argument.endswith('El') or argument.startswith('$'): + return True + elif argument.startswith('"') or argument.startswith("'"): + # a single literal string with no HTML is ok + # 1. it gets rid of false negatives for non-jquery calls (e.g. graph.append("g")) + # 2. JQuery will treat this as a plain text string and will escape any & if needed. + string = ParseString(argument, 0, len(argument)) + if string.string == argument and "<" not in argument: + return True + elif argument.startswith('$('): + # match on JQuery calls with single string and single HTML tag + # Examples: + # $("") + # $("
") + # $("
", {...}) + match = re.search(r"""\$\(\s*['"]<[a-zA-Z0-9]+\s*[/]?>['"]\s*[,)]""", argument) + if match is not None: + return True + elif self._is_jquery_argument_safe_html_utils_call(argument): + return True + # check rules that shouldn't use concatenation + elif "+" not in argument: + if argument.endswith('.el') or argument.endswith('.$el'): + return True + return False + + def _is_jquery_html_argument_safe(self, argument): + """ + Check the argument sent to the jQuery html() function to check if it is + safe. + + Safe arguments to html(): + - no argument (i.e. getter rather than setter) + - empty string is safe + - the argument can be a call to HtmlUtils.xxx(html).toString() + + Arguments: + argument: The argument sent to html() in code (i.e. html(argument)). + + Returns: + True if the argument is safe, and False otherwise. + + """ + if argument == "" or argument == "''" or argument == '""': + return True + elif self._is_jquery_argument_safe_html_utils_call(argument): + return True + return False + + def _is_jquery_insert_caller_safe(self, caller_line_start): + """ + Check that the caller of a jQuery DOM insertion function that takes a + target is safe (e.g. thisEl.appendTo(target)). + + If original line was:: + + draggableObj.iconEl.appendTo(draggableObj.containerEl); + + Parameter caller_line_start would be: + + draggableObj.iconEl + + Safe callers include: + - the caller can be ".el", ".$el" + - the caller can be a single variable ending in "El" or starting with + "$". For example, "testEl" or "$test". + + Arguments: + caller_line_start: The line leading up to the jQuery function call. + + Returns: + True if the caller is safe, and False otherwise. + + """ + # matches end of line for caller, which can't itself be a function + caller_match = re.search(r"(?:\s*|[.])([_$a-zA-Z]+[_$a-zA-Z0-9])*$", caller_line_start) + if caller_match is None: + return False + caller = caller_match.group(1) + if caller is None: + return False + elif caller.endswith('El') or caller.startswith('$'): + return True + elif caller == 'el' or caller == 'parentNode': + return True + return False + + def _check_concat_with_html(self, file_contents, results): + """ + Checks that strings with HTML are not concatenated + + Arguments: + file_contents: The contents of the JavaScript file. + results: A file results objects to which violations will be added. + + """ + lines = StringLines(file_contents) + last_expression = None + # attempt to match a string that starts with '<' or ends with '>' + regex_string_with_html = r"""["'](?:\s*<.*|.*>\s*)["']""" + regex_concat_with_html = r"(\+\s*{}|{}\s*\+)".format(regex_string_with_html, regex_string_with_html) + for match in re.finditer(regex_concat_with_html, file_contents): + found_new_violation = False + if last_expression is not None: + last_line = lines.index_to_line_number(last_expression.start_index) + # check if violation should be expanded to more of the same line + if last_line == lines.index_to_line_number(match.start()): + last_expression = Expression( + last_expression.start_index, match.end(), template=file_contents + ) + else: + results.violations.append(ExpressionRuleViolation( + Rules.javascript_concat_html, last_expression + )) + found_new_violation = True + else: + found_new_violation = True + if found_new_violation: + last_expression = Expression( + match.start(), match.end(), template=file_contents + ) + + # add final expression + if last_expression is not None: + results.violations.append(ExpressionRuleViolation( + Rules.javascript_concat_html, last_expression + )) + + class MakoTemplateLinter(object): """ The linter for Mako template files. """ _skip_mako_dirs = _skip_dirs + javaScriptLinter = JavaScriptLinter() def process_file(self, directory, file_name): """ @@ -677,7 +1465,7 @@ class MakoTemplateLinter(object): The file results containing any violations. """ - mako_template = _load_file(self, mako_file_full_path) + mako_template = _load_file(mako_file_full_path) self._check_mako_file_is_safe(mako_template, results) return results @@ -692,15 +1480,9 @@ class MakoTemplateLinter(object): """ if self._is_django_template(mako_template): return - has_page_default = False - if self._has_multiple_page_tags(mako_template): - results.violations.append(RuleViolation(Rules.mako_multiple_page_tags)) - else: - has_page_default = self._has_page_default(mako_template) - if not has_page_default: - results.violations.append(RuleViolation(Rules.mako_missing_default)) + has_page_default = self._has_page_default(mako_template, results) self._check_mako_expressions(mako_template, has_page_default, results) - results.prepare_results(mako_template) + results.prepare_results(mako_template, line_comment_delim='##') def _is_django_template(self, mako_template): """ @@ -717,33 +1499,58 @@ class MakoTemplateLinter(object): return True return False - def _has_multiple_page_tags(self, mako_template): + def _get_page_tag_count(self, mako_template): """ - Checks if the Mako template contains more than one page expression. + Determines the number of page expressions in the Mako template. Ignores + page expressions that are commented out. Arguments: mako_template: The contents of the Mako template. + Returns: + The number of page expressions """ count = len(re.findall('<%page ', mako_template, re.IGNORECASE)) - return count > 1 + count_commented = len(re.findall(r'##\s+<%page ', mako_template, re.IGNORECASE)) + return max(0, count - count_commented) - def _has_page_default(self, mako_template): + def _has_page_default(self, mako_template, results): """ Checks if the Mako template contains the page expression marking it as safe by default. Arguments: mako_template: The contents of the Mako template. + results: A list of results into which violations will be added. + + Side effect: + Adds violations regarding page default if necessary + + Returns: + True if the template has the page default, and False otherwise. """ + page_tag_count = self._get_page_tag_count(mako_template) + # check if there are too many page expressions + if 2 <= page_tag_count: + results.violations.append(RuleViolation(Rules.mako_multiple_page_tags)) + return False + # make sure there is exactly 1 page expression, excluding commented out + # page expressions, before proceeding + elif page_tag_count != 1: + results.violations.append(RuleViolation(Rules.mako_missing_default)) + return False + # check that safe by default (h filter) is turned on page_h_filter_regex = re.compile('<%page[^>]*expression_filter=(?:"h"|\'h\')[^>]*/>') page_match = page_h_filter_regex.search(mako_template) + if not page_match: + results.violations.append(RuleViolation(Rules.mako_missing_default)) return page_match def _check_mako_expressions(self, mako_template, has_page_default, results): """ Searches for Mako expressions and then checks if they contain + violations, including checking JavaScript contexts for JavaScript violations. Arguments: @@ -755,30 +1562,84 @@ class MakoTemplateLinter(object): """ expressions = self._find_mako_expressions(mako_template) contexts = self._get_contexts(mako_template) + self._check_javascript_contexts(mako_template, contexts, results) for expression in expressions: - if expression['expression'] is None: + if expression.end_index is None: results.violations.append(ExpressionRuleViolation( Rules.mako_unparseable_expression, expression )) continue - context = self._get_context(contexts, expression['start_index']) + context = self._get_context(contexts, expression.start_index) self._check_filters(mako_template, expression, context, has_page_default, results) self._check_deprecated_display_name(expression, results) self._check_html_and_text(expression, has_page_default, results) + def _check_javascript_contexts(self, mako_template, contexts, results): + """ + Lint the JavaScript contexts for JavaScript violations inside a Mako + template. + + Arguments: + mako_template: The contents of the Mako template. + contexts: A list of context dicts with 'type' and 'index'. + results: A list of results into which violations will be added. + + Side effect: + Adds JavaScript violations to results. + """ + javascript_start_index = None + for context in contexts: + if context['type'] == 'javascript': + if javascript_start_index < 0: + javascript_start_index = context['index'] + else: + if javascript_start_index is not None: + javascript_end_index = context['index'] + javascript_code = mako_template[javascript_start_index:javascript_end_index] + self._check_javascript_context(javascript_code, javascript_start_index, results) + javascript_start_index = None + if javascript_start_index is not None: + javascript_code = mako_template[javascript_start_index:] + self._check_javascript_context(javascript_code, javascript_start_index, results) + + def _check_javascript_context(self, javascript_code, start_offset, results): + """ + Lint a single JavaScript context for JavaScript violations inside a Mako + template. + + Arguments: + javascript_code: The template contents of the JavaScript context. + start_offset: The offset of the JavaScript context inside the + original Mako template. + results: A list of results into which violations will be added. + + Side effect: + Adds JavaScript violations to results. + + """ + javascript_results = FileResults("") + self.javaScriptLinter.check_javascript_file_is_safe(javascript_code, javascript_results) + # translate the violations into the location within the original + # Mako template + for violation in javascript_results.violations: + expression = violation.expression + expression.start_index += start_offset + if expression.end_index is not None: + expression.end_index += start_offset + results.violations.append(ExpressionRuleViolation(violation.rule, expression)) + def _check_deprecated_display_name(self, expression, results): """ Checks that the deprecated display_name_with_default_escaped is not used. Adds violation to results if there is a problem. Arguments: - expression: A dict containing the start_index, end_index, and - expression (text) of the expression. + expression: An Expression results: A list of results into which violations will be added. """ - if '.display_name_with_default_escaped' in expression['expression']: + if '.display_name_with_default_escaped' in expression.expression: results.violations.append(ExpressionRuleViolation( Rules.mako_deprecated_display_name, expression )) @@ -788,22 +1649,21 @@ class MakoTemplateLinter(object): Checks rules related to proper use of HTML() and Text(). Arguments: - expression: A dict containing the start_index, end_index, and - expression (text) of the expression. + expression: A Mako Expression. has_page_default: True if the page is marked as default, False otherwise. results: A list of results into which violations will be added. """ - # strip '${' and '}' and whitespace from ends - expression_inner = expression['expression'][2:-1].strip() - # find the template relative inner expression start index - # - find used to take into account above strip() - template_inner_start_index = expression['start_index'] + expression['expression'].find(expression_inner) + expression_inner = expression.expression_inner + # use find to get the template relative inner expression start index + # due to possible skipped white space + template_inner_start_index = expression.start_index + template_inner_start_index += expression.expression.find(expression_inner) if 'HTML(' in expression_inner: if expression_inner.startswith('HTML('): - close_paren_index = self._find_closing_char_index( - None, "(", ")", expression_inner, start_index=len('HTML('), num_open_chars=0, strings=[] + close_paren_index = _find_closing_char_index( + None, "(", ")", expression_inner, start_index=len('HTML(') )['close_char_index'] # check that the close paren is at the end of the stripped expression. if close_paren_index != len(expression_inner) - 1: @@ -821,13 +1681,11 @@ class MakoTemplateLinter(object): )) # strings to be checked for HTML - unwrapped_html_strings = expression['strings'] + unwrapped_html_strings = expression.strings for match in re.finditer(r"(HTML\(|Text\()", expression_inner): - result = self._find_closing_char_index( - None, "(", ")", expression_inner, start_index=match.end(), num_open_chars=0, strings=[] - ) - close_paren_index = result['close_char_index'] - if 0 <= close_paren_index: + result = _find_closing_char_index(None, "(", ")", expression_inner, start_index=match.end()) + if result is not None: + close_paren_index = result['close_char_index'] # the argument sent to HTML() or Text() argument = expression_inner[match.end():close_paren_index] if ".format(" in argument: @@ -865,8 +1723,7 @@ class MakoTemplateLinter(object): Arguments: mako_template: The contents of the Mako template. - expression: A dict containing the start_index, end_index, and - expression (text) of the expression. + expression: A Mako Expression. context: The context of the page in which the expression was found (e.g. javascript, html). has_page_default: True if the page is marked as default, False @@ -874,9 +1731,9 @@ class MakoTemplateLinter(object): results: A list of results into which violations will be added. """ - # finds "| n, h}" when given "${x | n, h}" - filters_regex = re.compile('\|[a-zA-Z_,\s]*\}') - filters_match = filters_regex.search(expression['expression']) + # Example: finds "| n, h}" when given "${x | n, h}" + filters_regex = re.compile(r'\|([.,\w\s]*)\}') + filters_match = filters_regex.search(expression.expression) if filters_match is None: if context == 'javascript': results.violations.append(ExpressionRuleViolation( @@ -884,12 +1741,12 @@ class MakoTemplateLinter(object): )) return - filters = filters_match.group()[1:-1].replace(" ", "").split(",") - if (len(filters) == 2) and (filters[0] == 'n') and (filters[1] == 'unicode'): - # {x | n, unicode} is valid in any context + filters = filters_match.group(1).replace(" ", "").split(",") + if filters == ['n', 'decode.utf8']: + # {x | n, decode.utf8} is valid in any context pass elif context == 'html': - if (len(filters) == 1) and (filters[0] == 'h'): + if filters == ['h']: if has_page_default: # suppress this violation if the page default hasn't been set, # otherwise the template might get less safe @@ -900,27 +1757,103 @@ class MakoTemplateLinter(object): results.violations.append(ExpressionRuleViolation( Rules.mako_invalid_html_filter, expression )) - - else: - if (len(filters) == 2) and (filters[0] == 'n') and (filters[1] == 'dump_js_escaped_json'): + elif context == 'javascript': + self._check_js_expression_not_with_html(mako_template, expression, results) + if filters == ['n', 'dump_js_escaped_json']: # {x | n, dump_js_escaped_json} is valid pass - elif (len(filters) == 2) and (filters[0] == 'n') and (filters[1] == 'js_escaped_string'): + elif filters == ['n', 'js_escaped_string']: # {x | n, js_escaped_string} is valid, if surrounded by quotes - pass + self._check_js_string_expression_in_quotes(mako_template, expression, results) else: results.violations.append(ExpressionRuleViolation( Rules.mako_invalid_js_filter, expression )) + def _check_js_string_expression_in_quotes(self, mako_template, expression, results): + """ + Checks that a Mako expression using js_escaped_string is surrounded by + quotes. + + Arguments: + mako_template: The contents of the Mako template. + expression: A Mako Expression. + results: A list of results into which violations will be added. + """ + parse_string = self._find_string_wrapping_expression(mako_template, expression) + if parse_string is None: + results.violations.append(ExpressionRuleViolation( + Rules.mako_js_missing_quotes, expression + )) + + def _check_js_expression_not_with_html(self, mako_template, expression, results): + """ + Checks that a Mako expression in a JavaScript context does not appear in + a string that also contains HTML. + + Arguments: + mako_template: The contents of the Mako template. + expression: A Mako Expression. + results: A list of results into which violations will be added. + """ + parse_string = self._find_string_wrapping_expression(mako_template, expression) + if parse_string is not None and re.search('[<>]', parse_string.string) is not None: + results.violations.append(ExpressionRuleViolation( + Rules.mako_js_html_string, expression + )) + + def _find_string_wrapping_expression(self, mako_template, expression): + """ + Finds the string wrapping the Mako expression if there is one. + + Arguments: + mako_template: The contents of the Mako template. + expression: A Mako Expression. + + Returns: + ParseString representing a scrubbed version of the wrapped string, + where the Mako expression was replaced with "${...}", if a wrapped + string was found. Otherwise, returns None if none found. + """ + lines = StringLines(mako_template) + start_index = lines.index_to_line_start_index(expression.start_index) + if expression.end_index is not None: + end_index = lines.index_to_line_end_index(expression.end_index) + else: + return None + # scrub out the actual expression so any code inside the expression + # doesn't interfere with rules applied to the surrounding code (i.e. + # checking JavaScript). + scrubbed_lines = "".join(( + mako_template[start_index:expression.start_index], + "${...}", + mako_template[expression.end_index:end_index] + )) + adjusted_start_index = expression.start_index - start_index + start_index = 0 + while True: + parse_string = ParseString(scrubbed_lines, start_index, len(scrubbed_lines)) + # check for validly parsed string + if 0 <= parse_string.start_index < parse_string.end_index: + # check if expression is contained in the given string + if parse_string.start_index < adjusted_start_index < parse_string.end_index: + return parse_string + else: + # move to check next string + start_index = parse_string.end_index + else: + break + return None + def _get_contexts(self, mako_template): """ Returns a data structure that represents the indices at which the template changes from HTML context to JavaScript and back. Return: - A list of dicts where each dict contains the 'index' of the context - and the context 'type' (e.g. 'html' or 'javascript'). + A list of dicts where each dict contains: + - index: the index of the context. + - type: the context type (e.g. 'html' or 'javascript'). """ contexts_re = re.compile(r""" | # script tag start @@ -930,6 +1863,7 @@ class MakoTemplateLinter(object): media_type_re = re.compile(r"""type=['"].*?['"]""", re.IGNORECASE) contexts = [{'index': 0, 'type': 'html'}] + javascript_types = ['text/javascript', 'text/ecmascript', 'application/ecmascript', 'application/javascript'] for context in contexts_re.finditer(mako_template): match_string = context.group().lower() if match_string.startswith(" - <%= _.escape(message) %> - - Arguments: - expression: The expression being checked. - - Returns: - True if the expression has been safely escaped, and False otherwise. - - """ - if expression['expression_inner'].startswith('HtmlUtils.'): - return True - if expression['expression_inner'].startswith('_.escape('): - return True - return False - - def _find_unescaped_expressions(self, underscore_template): - """ - Returns a list of unsafe expressions. - - At this time all expressions that are unescaped are considered unsafe. - - Arguments: - underscore_template: The contents of the Underscore.js template. - - Returns: - A list of dicts for each expression, where the dict contains the - following: - - start_index: The index of the start of the expression. - end_index: The index of the end of the expression. - expression: The text of the expression. - """ - unescaped_expression_regex = re.compile("<%=(.*?)%>", re.DOTALL) - - expressions = [] - for match in unescaped_expression_regex.finditer(underscore_template): - expression = { - 'start_index': match.start(), - 'end_index': match.end(), - 'expression': match.group(), - 'expression_inner': match.group(1).strip() - } + # restart search after the current expression + start_index = expression.end_index expressions.append(expression) - return expressions @@ -1265,12 +1966,17 @@ def _process_file(full_path, template_linters, options, out): options: A list of the options. out: output file + Returns: + The number of violations. + """ + num_violations = 0 directory = os.path.dirname(full_path) - file = os.path.basename(full_path) + file_name = os.path.basename(full_path) for template_linter in template_linters: - results = template_linter.process_file(directory, file) - results.print_results(options, out) + results = template_linter.process_file(directory, file_name) + num_violations += results.print_results(options, out) + return num_violations def _process_current_walk(current_walk, template_linters, options, out): @@ -1284,12 +1990,17 @@ def _process_current_walk(current_walk, template_linters, options, out): options: A list of the options. out: output file + Returns: + The number of violations. + """ + num_violations = 0 walk_directory = os.path.normpath(current_walk[0]) walk_files = current_walk[2] for walk_file in walk_files: full_path = os.path.join(walk_directory, walk_file) - _process_file(full_path, template_linters, options, out) + num_violations += _process_file(full_path, template_linters, options, out) + return num_violations def _process_os_walk(starting_dir, template_linters, options, out): @@ -1302,29 +2013,14 @@ def _process_os_walk(starting_dir, template_linters, options, out): options: A list of the options. out: output file - """ - for current_walk in os.walk(starting_dir): - _process_current_walk(current_walk, template_linters, options, out) - - -def _parse_arg(arg, option): - """ - Parses an argument searching for --[option]=[OPTION_VALUE] - - Arguments: - arg: The system argument - option: The specific option to be searched for (e.g. "file") - Returns: - The option value for a match, or None if arg is not for this option + The number of violations. + """ - if arg.startswith('--{}='.format(option)): - option_value = arg.split('=')[1] - if option_value.startswith("'") or option_value.startswith('"'): - option_value = option_value[1:-1] - return option_value - else: - return None + num_violations = 0 + for current_walk in os.walk(starting_dir): + num_violations += _process_current_walk(current_walk, template_linters, options, out) + return num_violations def main(): @@ -1352,15 +2048,20 @@ def main(): 'is_quiet': args.quiet, } - template_linters = [MakoTemplateLinter(), UnderscoreTemplateLinter()] + template_linters = [MakoTemplateLinter(), UnderscoreTemplateLinter(), JavaScriptLinter()] if args.file is not None: if os.path.isfile(args.file[0]) is False: raise ValueError("File [{}] is not a valid file.".format(args.file[0])) - _process_file(args.file[0], template_linters, options, out=sys.stdout) + num_violations = _process_file(args.file[0], template_linters, options, out=sys.stdout) else: if os.path.exists(args.directory[0]) is False or os.path.isfile(args.directory[0]) is True: raise ValueError("Directory [{}] is not a valid directory.".format(args.directory[0])) - _process_os_walk(args.directory[0], template_linters, options, out=sys.stdout) + num_violations = _process_os_walk(args.directory[0], template_linters, options, out=sys.stdout) + + if options['is_quiet'] is False: + # matches output of jshint for simplicity + print("") + print("{} violations found".format(num_violations)) if __name__ == "__main__": diff --git a/scripts/tests/templates/test.coffee b/scripts/tests/templates/test.coffee new file mode 100644 index 0000000000..53c25c9340 --- /dev/null +++ b/scripts/tests/templates/test.coffee @@ -0,0 +1,2 @@ +var x = "" + message + "" +var template = "<%= invalid %>" diff --git a/scripts/tests/templates/test.js b/scripts/tests/templates/test.js new file mode 100644 index 0000000000..71c425e0af --- /dev/null +++ b/scripts/tests/templates/test.js @@ -0,0 +1,6 @@ +var message = "Rock & Roll"; +var x = "" + message + ""; +var template = "<%= invalid %>"; +// quiet the linter +alert(x); +alert(template); diff --git a/scripts/tests/templates/test.underscore b/scripts/tests/templates/test.underscore new file mode 100644 index 0000000000..57fedb860a --- /dev/null +++ b/scripts/tests/templates/test.underscore @@ -0,0 +1 @@ +<%= invalid %> diff --git a/scripts/tests/test_safe_template_linter.py b/scripts/tests/test_safe_template_linter.py index 3a1b1dfd24..c85210838f 100644 --- a/scripts/tests/test_safe_template_linter.py +++ b/scripts/tests/test_safe_template_linter.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """ Tests for safe_template_linter.py """ @@ -9,16 +10,68 @@ import textwrap from unittest import TestCase from ..safe_template_linter import ( - _process_os_walk, FileResults, MakoTemplateLinter, ParseString, UnderscoreTemplateLinter, Rules + _process_os_walk, FileResults, JavaScriptLinter, MakoTemplateLinter, ParseString, StringLines, + UnderscoreTemplateLinter, Rules ) +@ddt +class TestStringLines(TestCase): + """ + Test StringLines class. + """ + @data( + {'string': 'test', 'index': 0, 'line_start_index': 0, 'line_end_index': 4}, + {'string': 'test', 'index': 2, 'line_start_index': 0, 'line_end_index': 4}, + {'string': 'test', 'index': 3, 'line_start_index': 0, 'line_end_index': 4}, + {'string': '\ntest', 'index': 0, 'line_start_index': 0, 'line_end_index': 1}, + {'string': '\ntest', 'index': 2, 'line_start_index': 1, 'line_end_index': 5}, + {'string': '\ntest\n', 'index': 0, 'line_start_index': 0, 'line_end_index': 1}, + {'string': '\ntest\n', 'index': 2, 'line_start_index': 1, 'line_end_index': 6}, + {'string': '\ntest\n', 'index': 6, 'line_start_index': 6, 'line_end_index': 6}, + ) + def test_string_lines_start_end_index(self, data): + """ + Test StringLines index_to_line_start_index and index_to_line_end_index. + """ + lines = StringLines(data['string']) + self.assertEqual(lines.index_to_line_start_index(data['index']), data['line_start_index']) + self.assertEqual(lines.index_to_line_end_index(data['index']), data['line_end_index']) + + @data( + {'string': 'test', 'line_number': 1, 'line': 'test'}, + {'string': '\ntest', 'line_number': 1, 'line': ''}, + {'string': '\ntest', 'line_number': 2, 'line': 'test'}, + {'string': '\ntest\n', 'line_number': 1, 'line': ''}, + {'string': '\ntest\n', 'line_number': 2, 'line': 'test'}, + {'string': '\ntest\n', 'line_number': 3, 'line': ''}, + ) + def test_string_lines_start_end_index(self, data): + """ + Test line_number_to_line. + """ + lines = StringLines(data['string']) + self.assertEqual(lines.line_number_to_line(data['line_number']), data['line']) + + +class TestLinter(TestCase): + """ + Test Linter base class + """ + def _validate_data_rule(self, data, results): + if data['rule'] is None: + self.assertEqual(len(results.violations), 0) + else: + self.assertEqual(len(results.violations), 1) + self.assertEqual(results.violations[0].rule, data['rule']) + + class TestSafeTemplateLinter(TestCase): """ Test some top-level linter functions """ - def test_process_os_walk_with_includes(self): + def test_process_os_walk(self): """ Tests the top-level processing of template files, including Mako includes. @@ -29,17 +82,25 @@ class TestSafeTemplateLinter(TestCase): 'is_quiet': False, } - template_linters = [MakoTemplateLinter(), UnderscoreTemplateLinter()] + template_linters = [MakoTemplateLinter(), JavaScriptLinter(), UnderscoreTemplateLinter()] - with mock.patch.object(MakoTemplateLinter, '_is_valid_directory', return_value=True) as mock_is_valid_directory: - _process_os_walk('scripts/tests/templates', template_linters, options, out) + with mock.patch.object(MakoTemplateLinter, '_is_valid_directory', return_value=True): + with mock.patch.object(JavaScriptLinter, '_is_valid_directory', return_value=True): + with mock.patch.object(UnderscoreTemplateLinter, '_is_valid_directory', return_value=True): + num_violations = _process_os_walk('scripts/tests/templates', template_linters, options, out) output = out.getvalue() - self.assertIsNotNone(re.search('test\.html.*mako-missing-default', out.getvalue())) + self.assertEqual(num_violations, 6) + self.assertIsNotNone(re.search('test\.html.*mako-missing-default', output)) + self.assertIsNotNone(re.search('test\.coffee.*javascript-concat-html', output)) + self.assertIsNotNone(re.search('test\.coffee.*underscore-not-escaped', output)) + self.assertIsNotNone(re.search('test\.js.*javascript-concat-html', output)) + self.assertIsNotNone(re.search('test\.js.*underscore-not-escaped', output)) + self.assertIsNotNone(re.search('test\.underscore.*underscore-not-escaped', output)) @ddt -class TestMakoTemplateLinter(TestCase): +class TestMakoTemplateLinter(TestLinter): """ Test MakoTemplateLinter """ @@ -63,36 +124,40 @@ class TestMakoTemplateLinter(TestCase): @data( { 'template': '\n <%page expression_filter="h"/>', - 'violations': 0, 'rule': None }, { 'template': '\n <%page args="section_data" expression_filter="h" /> ', - 'violations': 0, 'rule': None }, + { + 'template': '\n ## <%page expression_filter="h"/>', + 'rule': Rules.mako_missing_default + }, { 'template': '\n <%page expression_filter="h" /> ' '\n <%page args="section_data"/>', - 'violations': 1, 'rule': Rules.mako_multiple_page_tags }, + { + 'template': + '\n <%page expression_filter="h" /> ' + '\n ## <%page args="section_data"/>', + 'rule': None + }, { 'template': '\n <%page args="section_data" /> ', - 'violations': 1, 'rule': Rules.mako_missing_default }, { 'template': '\n <%page args="section_data"/> ', - 'violations': 1, 'rule': Rules.mako_missing_default }, { 'template': '\n', - 'violations': 1, 'rule': Rules.mako_missing_default }, ) @@ -105,16 +170,18 @@ class TestMakoTemplateLinter(TestCase): linter._check_mako_file_is_safe(data['template'], results) - self.assertEqual(len(results.violations), data['violations']) - if data['violations'] > 0: + num_violations = 0 if data['rule'] is None else 1 + self.assertEqual(len(results.violations), num_violations) + if num_violations > 0: self.assertEqual(results.violations[0].rule, data['rule']) @data( {'expression': '${x}', 'rule': None}, {'expression': '${{unbalanced}', 'rule': Rules.mako_unparseable_expression}, {'expression': '${x | n}', 'rule': Rules.mako_invalid_html_filter}, - {'expression': '${x | n, unicode}', 'rule': None}, + {'expression': '${x | n, decode.utf8}', 'rule': None}, {'expression': '${x | h}', 'rule': Rules.mako_unwanted_html_filter}, + {'expression': ' ## ${commented_out | h}', 'rule': None}, {'expression': '${x | n, dump_js_escaped_json}', 'rule': Rules.mako_invalid_html_filter}, ) def test_check_mako_expressions_in_html(self, data): @@ -358,7 +425,7 @@ class TestMakoTemplateLinter(TestCase): {'expression': '${x | n}', 'rule': Rules.mako_invalid_js_filter}, {'expression': '${x | h}', 'rule': Rules.mako_invalid_js_filter}, {'expression': '${x | n, dump_js_escaped_json}', 'rule': None}, - {'expression': '${x | n, unicode}', 'rule': None}, + {'expression': '${x | n, decode.utf8}', 'rule': None}, ) def test_check_mako_expressions_in_javascript(self, data): """ @@ -381,7 +448,7 @@ class TestMakoTemplateLinter(TestCase): @data( {'expression': '${x}', 'rule': Rules.mako_invalid_js_filter}, - {'expression': '${x | n, js_escaped_string}', 'rule': None}, + {'expression': '"${x | n, js_escaped_string}"', 'rule': None}, ) def test_check_mako_expressions_in_require_js(self, data): """ @@ -458,6 +525,63 @@ class TestMakoTemplateLinter(TestCase): self.assertEqual(results.violations[3].rule, Rules.mako_invalid_js_filter) self.assertEqual(results.violations[4].rule, Rules.mako_unwanted_html_filter) + def test_check_mako_expressions_javascript_strings(self): + """ + Test _check_mako_file_is_safe javascript string specific rules. + - mako_js_missing_quotes + - mako_js_html_string + """ + linter = MakoTemplateLinter() + results = FileResults('') + + mako_template = textwrap.dedent(""" + <%page expression_filter="h"/> + + """) + + linter._check_mako_file_is_safe(mako_template, results) + + self.assertEqual(len(results.violations), 3) + self.assertEqual(results.violations[0].rule, Rules.mako_js_missing_quotes) + self.assertEqual(results.violations[1].rule, Rules.mako_js_html_string) + self.assertEqual(results.violations[2].rule, Rules.mako_js_html_string) + + def test_check_javascript_in_mako_javascript_context(self): + """ + Test _check_mako_file_is_safe with JavaScript error in JavaScript + context. + """ + linter = MakoTemplateLinter() + results = FileResults('') + + mako_template = textwrap.dedent(""" + <%page expression_filter="h"/> + + """) + + linter._check_mako_file_is_safe(mako_template, results) + + self.assertEqual(len(results.violations), 1) + self.assertEqual(results.violations[0].rule, Rules.javascript_concat_html) + self.assertEqual(results.violations[0].start_line, 4) + @data( {'template': "\n${x | n}", 'parseable': True}, { @@ -516,10 +640,10 @@ class TestMakoTemplateLinter(TestCase): expressions = linter._find_mako_expressions(data['template']) self.assertEqual(len(expressions), 1) - start_index = expressions[0]['start_index'] - end_index = expressions[0]['end_index'] + start_index = expressions[0].start_index + end_index = expressions[0].end_index self.assertEqual(data['template'][start_index:end_index], data['template'].strip()) - self.assertEqual(expressions[0]['expression'], data['template'].strip()) + self.assertEqual(expressions[0].expression, data['template'].strip()) @data( {'template': " ${{unparseable} ${}", 'start_index': 1}, @@ -533,8 +657,8 @@ class TestMakoTemplateLinter(TestCase): expressions = linter._find_mako_expressions(data['template']) self.assertTrue(2 <= len(expressions)) - self.assertEqual(expressions[0]['start_index'], data['start_index']) - self.assertIsNone(expressions[0]['expression']) + self.assertEqual(expressions[0].start_index, data['start_index']) + self.assertIsNone(expressions[0].expression) @data( { @@ -557,6 +681,10 @@ class TestMakoTemplateLinter(TestCase): 'template': r""" ${" \" \\"} """, 'result': {'start_index': 3, 'end_index': 11, 'quote_length': 1} }, + { + 'template': "${'broken string}", + 'result': {'start_index': 2, 'end_index': None, 'quote_length': None} + }, ) def test_parse_string(self, data): """ @@ -572,28 +700,22 @@ class TestMakoTemplateLinter(TestCase): } self.assertDictEqual(string_dict, data['result']) - self.assertEqual(data['template'][parse_string.start_index:parse_string.end_index], parse_string.string) - start_index = parse_string.start_index + parse_string.quote_length - end_index = parse_string.end_index - parse_string.quote_length - self.assertEqual(data['template'][start_index:end_index], parse_string.string_inner) - - def _validate_data_rule(self, data, results): - if data['rule'] is None: - self.assertEqual(len(results.violations), 0) - else: - self.assertEqual(len(results.violations), 1) - self.assertEqual(results.violations[0].rule, data['rule']) + if parse_string.end_index is not None: + self.assertEqual(data['template'][parse_string.start_index:parse_string.end_index], parse_string.string) + start_inner_index = parse_string.start_index + parse_string.quote_length + end_inner_index = parse_string.end_index - parse_string.quote_length + self.assertEqual(data['template'][start_inner_index:end_inner_index], parse_string.string_inner) @ddt -class TestUnderscoreTemplateLinter(TestCase): +class TestUnderscoreTemplateLinter(TestLinter): """ Test UnderscoreTemplateLinter """ def test_check_underscore_file_is_safe(self): """ - Test _check_underscore_file_is_safe with safe template + Test check_underscore_file_is_safe with safe template """ linter = UnderscoreTemplateLinter() results = FileResults('') @@ -606,13 +728,13 @@ class TestUnderscoreTemplateLinter(TestCase): %> """) - linter._check_underscore_file_is_safe(template, results) + linter.check_underscore_file_is_safe(template, results) self.assertEqual(len(results.violations), 0) def test_check_underscore_file_is_not_safe(self): """ - Test _check_underscore_file_is_safe with unsafe template + Test check_underscore_file_is_safe with unsafe template """ linter = UnderscoreTemplateLinter() results = FileResults('') @@ -625,7 +747,7 @@ class TestUnderscoreTemplateLinter(TestCase): %> """) - linter._check_underscore_file_is_safe(template, results) + linter.check_underscore_file_is_safe(template, results) self.assertEqual(len(results.violations), 2) self.assertEqual(results.violations[0].rule, Rules.underscore_not_escaped) @@ -683,12 +805,12 @@ class TestUnderscoreTemplateLinter(TestCase): ) def test_check_underscore_file_disable_rule(self, data): """ - Test _check_underscore_file_is_safe with various disabled pragmas + Test check_underscore_file_is_safe with various disabled pragmas """ linter = UnderscoreTemplateLinter() results = FileResults('') - linter._check_underscore_file_is_safe(data['template'], results) + linter.check_underscore_file_is_safe(data['template'], results) violation_count = len(data['is_disabled']) self.assertEqual(len(results.violations), violation_count) @@ -697,7 +819,7 @@ class TestUnderscoreTemplateLinter(TestCase): def test_check_underscore_file_disables_one_violation(self): """ - Test _check_underscore_file_is_safe with disabled before a line only + Test check_underscore_file_is_safe with disabled before a line only disables for the violation following """ linter = UnderscoreTemplateLinter() @@ -709,7 +831,7 @@ class TestUnderscoreTemplateLinter(TestCase): <%= message %> """) - linter._check_underscore_file_is_safe(template, results) + linter.check_underscore_file_is_safe(template, results) self.assertEqual(len(results.violations), 2) self.assertEqual(results.violations[0].is_disabled, True) @@ -721,12 +843,212 @@ class TestUnderscoreTemplateLinter(TestCase): ) def test_check_underscore_no_escape_allowed(self, data): """ - Test _check_underscore_file_is_safe with expressions that are allowed + Test check_underscore_file_is_safe with expressions that are allowed without escaping because the internal calls properly escape. """ linter = UnderscoreTemplateLinter() results = FileResults('') - linter._check_underscore_file_is_safe(data['template'], results) + linter.check_underscore_file_is_safe(data['template'], results) self.assertEqual(len(results.violations), 0) + + +@ddt +class TestJavaScriptLinter(TestLinter): + """ + Test JavaScriptLinter + """ + @data( + {'template': 'var m = "Plain text " + message + "plain text"', 'rule': None}, + {'template': 'var m = "檌檒濦 " + message + "plain text"', 'rule': None}, + {'template': 'var m = "

" + message + "

"', 'rule': Rules.javascript_concat_html}, + {'template': ' // var m = "

" + commentedOutMessage + "

"', 'rule': None}, + {'template': 'var m = "

" + message + "

"', 'rule': Rules.javascript_concat_html}, + {'template': 'var m = "

" + message + " broken string', 'rule': Rules.javascript_concat_html}, + ) + def test_concat_with_html(self, data): + """ + Test check_javascript_file_is_safe with concatenating strings and HTML + """ + linter = JavaScriptLinter() + results = FileResults('') + + linter.check_javascript_file_is_safe(data['template'], results) + self._validate_data_rule(data, results) + + @data( + {'template': 'test.append( test.render().el )', 'rule': None}, + {'template': 'test.append(test.render().el)', 'rule': None}, + {'template': 'test.append(test.render().$el)', 'rule': None}, + {'template': 'test.append(testEl)', 'rule': None}, + {'template': 'test.append($test)', 'rule': None}, + # plain text is ok because any & will be escaped, and it stops false + # negatives on some other objects with an append() method + {'template': 'test.append("plain text")', 'rule': None}, + {'template': 'test.append("

")', 'rule': Rules.javascript_jquery_append}, + {'template': 'graph.svg.append("g")', 'rule': None}, + {'template': 'test.append( $( "
" ) )', 'rule': None}, + {'template': 'test.append($("
"))', 'rule': None}, + {'template': 'test.append($("
"))', 'rule': None}, + {'template': 'test.append(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, + {'template': 'HtmlUtils.append($el, someHtml)', 'rule': None}, + {'template': 'test.append("fail on concat" + test.render().el)', 'rule': Rules.javascript_jquery_append}, + {'template': 'test.append("fail on concat" + testEl)', 'rule': Rules.javascript_jquery_append}, + {'template': 'test.append(message)', 'rule': Rules.javascript_jquery_append}, + ) + def test_jquery_append(self, data): + """ + Test check_javascript_file_is_safe with JQuery append() + """ + linter = JavaScriptLinter() + results = FileResults('') + + linter.check_javascript_file_is_safe(data['template'], results) + + self._validate_data_rule(data, results) + + @data( + {'template': 'test.prepend( test.render().el )', 'rule': None}, + {'template': 'test.prepend(test.render().el)', 'rule': None}, + {'template': 'test.prepend(test.render().$el)', 'rule': None}, + {'template': 'test.prepend(testEl)', 'rule': None}, + {'template': 'test.prepend($test)', 'rule': None}, + {'template': 'test.prepend("text")', 'rule': None}, + {'template': 'test.prepend( $( "
" ) )', 'rule': None}, + {'template': 'test.prepend($("
"))', 'rule': None}, + {'template': 'test.prepend($("
"))', 'rule': None}, + {'template': 'test.prepend(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, + {'template': 'HtmlUtils.prepend($el, someHtml)', 'rule': None}, + {'template': 'test.prepend("broken string)', 'rule': Rules.javascript_jquery_prepend}, + {'template': 'test.prepend("fail on concat" + test.render().el)', 'rule': Rules.javascript_jquery_prepend}, + {'template': 'test.prepend("fail on concat" + testEl)', 'rule': Rules.javascript_jquery_prepend}, + {'template': 'test.prepend(message)', 'rule': Rules.javascript_jquery_prepend}, + ) + def test_jquery_prepend(self, data): + """ + Test check_javascript_file_is_safe with JQuery prepend() + """ + linter = JavaScriptLinter() + results = FileResults('') + + linter.check_javascript_file_is_safe(data['template'], results) + + self._validate_data_rule(data, results) + + @data( + {'template': 'test.unwrap(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, + {'template': 'test.wrap(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, + {'template': 'test.wrapAll(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, + {'template': 'test.wrapInner(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, + {'template': 'test.after(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, + {'template': 'test.before(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, + {'template': 'test.replaceAll(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, + {'template': 'test.replaceWith(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, + {'template': 'test.replaceWith(edx.HtmlUtils.HTML(htmlString).toString())', 'rule': None}, + {'template': 'test.unwrap(anything)', 'rule': Rules.javascript_jquery_insertion}, + {'template': 'test.wrap(anything)', 'rule': Rules.javascript_jquery_insertion}, + {'template': 'test.wrapAll(anything)', 'rule': Rules.javascript_jquery_insertion}, + {'template': 'test.wrapInner(anything)', 'rule': Rules.javascript_jquery_insertion}, + {'template': 'test.after(anything)', 'rule': Rules.javascript_jquery_insertion}, + {'template': 'test.before(anything)', 'rule': Rules.javascript_jquery_insertion}, + {'template': 'test.replaceAll(anything)', 'rule': Rules.javascript_jquery_insertion}, + {'template': 'test.replaceWith(anything)', 'rule': Rules.javascript_jquery_insertion}, + ) + def test_jquery_insertion(self, data): + """ + Test check_javascript_file_is_safe with JQuery insertion functions + other than append(), prepend() and html() that take content as an + argument (e.g. before(), after()). + """ + linter = JavaScriptLinter() + results = FileResults('') + + linter.check_javascript_file_is_safe(data['template'], results) + + self._validate_data_rule(data, results) + + @data( + {'template': ' element.parentNode.appendTo(target);', 'rule': None}, + {'template': ' test.render().el.appendTo(target);', 'rule': None}, + {'template': ' test.render().$el.appendTo(target);', 'rule': None}, + {'template': ' test.$element.appendTo(target);', 'rule': None}, + {'template': ' test.testEl.appendTo(target);', 'rule': None}, + {'template': '$element.appendTo(target);', 'rule': None}, + {'template': 'el.appendTo(target);', 'rule': None}, + {'template': 'testEl.appendTo(target);', 'rule': None}, + {'template': 'testEl.prependTo(target);', 'rule': None}, + {'template': 'testEl.insertAfter(target);', 'rule': None}, + {'template': 'testEl.insertBefore(target);', 'rule': None}, + {'template': 'anycall().appendTo(target)', 'rule': Rules.javascript_jquery_insert_into_target}, + {'template': 'anything.appendTo(target)', 'rule': Rules.javascript_jquery_insert_into_target}, + {'template': 'anything.prependTo(target)', 'rule': Rules.javascript_jquery_insert_into_target}, + {'template': 'anything.insertAfter(target)', 'rule': Rules.javascript_jquery_insert_into_target}, + {'template': 'anything.insertBefore(target)', 'rule': Rules.javascript_jquery_insert_into_target}, + ) + def test_jquery_insert_to_target(self, data): + """ + Test check_javascript_file_is_safe with JQuery insert to target + functions that take a target as an argument, like appendTo() and + prependTo(). + """ + linter = JavaScriptLinter() + results = FileResults('') + + linter.check_javascript_file_is_safe(data['template'], results) + + self._validate_data_rule(data, results) + + @data( + {'template': 'test.html()', 'rule': None}, + {'template': 'test.html( )', 'rule': None}, + {'template': "test.html( '' )", 'rule': None}, + {'template': "test.html('')", 'rule': None}, + {'template': 'test.html("")', 'rule': None}, + {'template': 'test.html(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, + {'template': 'HtmlUtils.setHtml($el, someHtml)', 'rule': None}, + {'template': 'test.html("any string")', 'rule': Rules.javascript_jquery_html}, + {'template': 'test.html("broken string)', 'rule': Rules.javascript_jquery_html}, + {'template': 'test.html("檌檒濦")', 'rule': Rules.javascript_jquery_html}, + {'template': 'test.html(anything)', 'rule': Rules.javascript_jquery_html}, + ) + def test_jquery_html(self, data): + """ + Test check_javascript_file_is_safe with JQuery html() + """ + linter = JavaScriptLinter() + results = FileResults('') + + linter.check_javascript_file_is_safe(data['template'], results) + self._validate_data_rule(data, results) + + @data( + {'template': 'StringUtils.interpolate()', 'rule': None}, + {'template': 'HtmlUtils.interpolateHtml()', 'rule': None}, + {'template': 'interpolate(anything)', 'rule': Rules.javascript_interpolate}, + ) + def test_javascript_interpolate(self, data): + """ + Test check_javascript_file_is_safe with interpolate() + """ + linter = JavaScriptLinter() + results = FileResults('') + + linter.check_javascript_file_is_safe(data['template'], results) + + self._validate_data_rule(data, results) + + @data( + {'template': '_.escape(message)', 'rule': None}, + {'template': 'anything.escape(message)', 'rule': Rules.javascript_escape}, + ) + def test_javascript_interpolate(self, data): + """ + Test check_javascript_file_is_safe with interpolate() + """ + linter = JavaScriptLinter() + results = FileResults('') + + linter.check_javascript_file_is_safe(data['template'], results) + + self._validate_data_rule(data, results)