Repository: oasis-open/cti-pattern-validator

Issue:
STIX 2.1 pattern validation failing on multiple START STOP qualifiers
When running the following from a terminal:
```
validate-patterns -v 2.1
[network-traffic:src_port = 37020 AND user-account:user_id = 'root'] START t'2016-06-01T01:30:00.123Z' STOP t'2016-06-01T02:20:00.123Z' OR [ipv4-addr:value = '192.168.122.83'] START t'2016-06-01T03:55:00.123Z' STOP t'2016-06-01T04:30:24.743Z'
```
I get a failure:
`FAIL: The same qualifier is used more than once`

I thought STIX patterns supported multiple START STOP qualifiers since the qualifier affects only the observation that immediately precedes it.  Am I wrong in this?

Discussion:

Patch:
diff --git a/.travis.yml b/.travis.yml
index 26be7f2..261f125 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,4 +1,4 @@
-os: linux
+sudo: false
 language: python
 cache: pip
 python:
@@ -6,15 +6,17 @@ python:
   - "3.4"
   - "3.5"
   - "3.6"
-  - "3.7"
-  - "3.8"
+matrix:
+  include:
+    - python: 3.7 # https://github.com/travis-ci/travis-ci/issues/9069#issuecomment-425720905
+      dist: xenial
+      sudo: true
 install:
   - pip install -U pip setuptools
-  - pip install tox-travis
-  - if [[ $TRAVIS_PYTHON_VERSION != 3.4 ]]; then pip install pre-commit; fi
+  - pip install tox-travis pre-commit
   - pip install codecov
 script:
   - tox
-  - if [[ $TRAVIS_PYTHON_VERSION != 3.4 ]]; then pre-commit run --all-files; fi
+  - pre-commit run --all-files
 after_success:
   - codecov
diff --git a/setup.py b/setup.py
index 36453d8..f55d406 100644
--- a/setup.py
+++ b/setup.py
@@ -20,7 +20,7 @@ dev_requires = doc_requires + test_requires + [
     'bumpversion',
     'check-manifest',
     'pre-commit',
-    # test_requires are installed into every tox environment, so we don't
+    # test_requires are installed into every tox environemnt, so we don't
     # want to include tox there.
     'tox',
 ]
@@ -38,10 +38,11 @@ setup(
     maintainer_email='clenk@mitre.org',
     packages=find_packages(),
     install_requires=[
-        'antlr4-python2-runtime~=4.8.0 ; python_version<"3"',
-        'antlr4-python3-runtime~=4.8.0 ; python_version>="3"',
+        'antlr4-python2-runtime>=4.7 ; python_version<"3"',
+        'antlr4-python3-runtime>=4.7 ; python_version>="3"',
         'six',
         'typing ; python_version<"3.5" and python_version>="3"',
+        'enum34 ; python_version<"3.4"',
     ],
     package_data={
         'stix2patterns.test.v20': ['spec_examples.txt'],
diff --git a/stix2patterns/v20/grammars/STIXPatternLexer.py b/stix2patterns/v20/grammars/STIXPatternLexer.py
index db09c9c..a6a1f51 100644
--- a/stix2patterns/v20/grammars/STIXPatternLexer.py
+++ b/stix2patterns/v20/grammars/STIXPatternLexer.py
@@ -1,4 +1,4 @@
-# Generated from STIXPattern.g4 by ANTLR 4.8
+# Generated from STIXPattern.g4 by ANTLR 4.7.1
 # encoding: utf-8
 from __future__ import print_function
 from antlr4 import *
@@ -6,7 +6,6 @@ from io import StringIO
 import sys
 
 
-
 def serializedATN():
     with StringIO() as buf:
         buf.write(u"\3\u608b\ua72a\u8133\ub9ed\u417c\u3be7\u7786\u5964\2")
@@ -343,7 +342,7 @@ class STIXPatternLexer(Lexer):
 
     def __init__(self, input=None, output=sys.stdout):
         super(STIXPatternLexer, self).__init__(input, output=output)
-        self.checkVersion("4.8")
+        self.checkVersion("4.7.1")
         self._interp = LexerATNSimulator(self, self.atn, self.decisionsToDFA, PredictionContextCache())
         self._actions = None
         self._predicates = None
diff --git a/stix2patterns/v20/grammars/STIXPatternListener.py b/stix2patterns/v20/grammars/STIXPatternListener.py
index af0b280..4af4467 100644
--- a/stix2patterns/v20/grammars/STIXPatternListener.py
+++ b/stix2patterns/v20/grammars/STIXPatternListener.py
@@ -1,4 +1,4 @@
-# Generated from STIXPattern.g4 by ANTLR 4.8
+# Generated from STIXPattern.g4 by ANTLR 4.7.1
 from antlr4 import *
 
 # This class defines a complete listener for a parse tree produced by STIXPatternParser.
diff --git a/stix2patterns/v20/grammars/STIXPatternParser.py b/stix2patterns/v20/grammars/STIXPatternParser.py
index 51174e9..65bbc4e 100644
--- a/stix2patterns/v20/grammars/STIXPatternParser.py
+++ b/stix2patterns/v20/grammars/STIXPatternParser.py
@@ -1,11 +1,10 @@
-# Generated from STIXPattern.g4 by ANTLR 4.8
+# Generated from STIXPattern.g4 by ANTLR 4.7.1
 # encoding: utf-8
 from __future__ import print_function
 from antlr4 import *
 from io import StringIO
 import sys
 
-
 def serializedATN():
     with StringIO() as buf:
         buf.write(u"\3\u608b\ua72a\u8133\ub9ed\u417c\u3be7\u7786\u5964\3")
@@ -226,13 +225,12 @@ class STIXPatternParser ( Parser ):
 
     def __init__(self, input, output=sys.stdout):
         super(STIXPatternParser, self).__init__(input, output=output)
-        self.checkVersion("4.8")
+        self.checkVersion("4.7.1")
         self._interp = ParserATNSimulator(self, self.atn, self.decisionsToDFA, self.sharedContextCache)
         self._predicates = None
 
 
 
-
     class PatternContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -284,7 +282,6 @@ class STIXPatternParser ( Parser ):
             self.exitRule()
         return localctx
 
-
     class ObservationExpressionsContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -366,7 +363,6 @@ class STIXPatternParser ( Parser ):
             self.unrollRecursionContexts(_parentctx)
         return localctx
 
-
     class ObservationExpressionOrContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -448,7 +444,6 @@ class STIXPatternParser ( Parser ):
             self.unrollRecursionContexts(_parentctx)
         return localctx
 
-
     class ObservationExpressionAndContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -530,7 +525,6 @@ class STIXPatternParser ( Parser ):
             self.unrollRecursionContexts(_parentctx)
         return localctx
 
-
     class ObservationExpressionContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -785,7 +779,6 @@ class STIXPatternParser ( Parser ):
             self.unrollRecursionContexts(_parentctx)
         return localctx
 
-
     class ComparisonExpressionContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -867,7 +860,6 @@ class STIXPatternParser ( Parser ):
             self.unrollRecursionContexts(_parentctx)
         return localctx
 
-
     class ComparisonExpressionAndContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -949,7 +941,6 @@ class STIXPatternParser ( Parser ):
             self.unrollRecursionContexts(_parentctx)
         return localctx
 
-
     class PropTestContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -1396,7 +1387,6 @@ class STIXPatternParser ( Parser ):
             self.exitRule()
         return localctx
 
-
     class StartStopQualifierContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -1457,7 +1447,6 @@ class STIXPatternParser ( Parser ):
             self.exitRule()
         return localctx
 
-
     class WithinQualifierContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -1522,7 +1511,6 @@ class STIXPatternParser ( Parser ):
             self.exitRule()
         return localctx
 
-
     class RepeatedQualifierContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -1578,7 +1566,6 @@ class STIXPatternParser ( Parser ):
             self.exitRule()
         return localctx
 
-
     class ObjectPathContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -1649,7 +1636,6 @@ class STIXPatternParser ( Parser ):
             self.exitRule()
         return localctx
 
-
     class ObjectTypeContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -1704,7 +1690,6 @@ class STIXPatternParser ( Parser ):
             self.exitRule()
         return localctx
 
-
     class FirstPathComponentContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -1759,7 +1744,6 @@ class STIXPatternParser ( Parser ):
             self.exitRule()
         return localctx
 
-
     class ObjectPathComponentContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -1841,8 +1825,6 @@ class STIXPatternParser ( Parser ):
             super(STIXPatternParser.KeyPathStepContext, self).__init__(parser)
             self.copyFrom(ctx)
 
-        def DOT(self):
-            return self.getToken(STIXPatternParser.DOT, 0)
         def IdentifierWithoutHyphen(self):
             return self.getToken(STIXPatternParser.IdentifierWithoutHyphen, 0)
         def StringLiteral(self):
@@ -1940,7 +1922,6 @@ class STIXPatternParser ( Parser ):
             self.unrollRecursionContexts(_parentctx)
         return localctx
 
-
     class SetLiteralContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -2034,7 +2015,6 @@ class STIXPatternParser ( Parser ):
             self.exitRule()
         return localctx
 
-
     class PrimitiveLiteralContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -2097,7 +2077,6 @@ class STIXPatternParser ( Parser ):
             self.exitRule()
         return localctx
 
-
     class OrderableLiteralContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
diff --git a/stix2patterns/v20/grammars/STIXPatternVisitor.py b/stix2patterns/v20/grammars/STIXPatternVisitor.py
index 177a3ed..78c84da 100644
--- a/stix2patterns/v20/grammars/STIXPatternVisitor.py
+++ b/stix2patterns/v20/grammars/STIXPatternVisitor.py
@@ -1,4 +1,4 @@
-# Generated from STIXPattern.g4 by ANTLR 4.8
+# Generated from STIXPattern.g4 by ANTLR 4.7.1
 from antlr4 import *
 
 # This class defines a complete generic visitor for a parse tree produced by STIXPatternParser.
diff --git a/stix2patterns/v20/pattern.py b/stix2patterns/v20/pattern.py
index 2603739..446e82c 100644
--- a/stix2patterns/v20/pattern.py
+++ b/stix2patterns/v20/pattern.py
@@ -40,15 +40,6 @@ class Pattern(object):
         subclass) instance."""
         antlr4.ParseTreeWalker.DEFAULT.walk(listener, self.__parse_tree)
 
-    def visit(self, visitor):
-        """
-        Walk the parse tree using the given visitor.
-
-        :param visitor: A visitor object (STIXPatternVisitor instance)
-        :return: The visitor's return value
-        """
-        return visitor.visit(self.__parse_tree)
-
     def __do_parse(self, pattern_str):
         """
         Parses the given pattern and returns the antlr parse tree.
diff --git a/stix2patterns/v21/grammars/STIXPatternLexer.py b/stix2patterns/v21/grammars/STIXPatternLexer.py
index bf1b059..e6c722e 100644
--- a/stix2patterns/v21/grammars/STIXPatternLexer.py
+++ b/stix2patterns/v21/grammars/STIXPatternLexer.py
@@ -1,4 +1,4 @@
-# Generated from STIXPattern.g4 by ANTLR 4.8
+# Generated from STIXPattern.g4 by ANTLR 4.7.1
 # encoding: utf-8
 from __future__ import print_function
 from antlr4 import *
@@ -6,7 +6,6 @@ from io import StringIO
 import sys
 
 
-
 def serializedATN():
     with StringIO() as buf:
         buf.write(u"\3\u608b\ua72a\u8133\ub9ed\u417c\u3be7\u7786\u5964\2")
@@ -347,7 +346,7 @@ class STIXPatternLexer(Lexer):
 
     def __init__(self, input=None, output=sys.stdout):
         super(STIXPatternLexer, self).__init__(input, output=output)
-        self.checkVersion("4.8")
+        self.checkVersion("4.7.1")
         self._interp = LexerATNSimulator(self, self.atn, self.decisionsToDFA, PredictionContextCache())
         self._actions = None
         self._predicates = None
diff --git a/stix2patterns/v21/grammars/STIXPatternListener.py b/stix2patterns/v21/grammars/STIXPatternListener.py
index 1b6c0ce..ad33415 100644
--- a/stix2patterns/v21/grammars/STIXPatternListener.py
+++ b/stix2patterns/v21/grammars/STIXPatternListener.py
@@ -1,4 +1,4 @@
-# Generated from STIXPattern.g4 by ANTLR 4.8
+# Generated from STIXPattern.g4 by ANTLR 4.7.1
 from antlr4 import *
 
 # This class defines a complete listener for a parse tree produced by STIXPatternParser.
diff --git a/stix2patterns/v21/grammars/STIXPatternParser.py b/stix2patterns/v21/grammars/STIXPatternParser.py
index f76d1f9..8e82c3b 100644
--- a/stix2patterns/v21/grammars/STIXPatternParser.py
+++ b/stix2patterns/v21/grammars/STIXPatternParser.py
@@ -1,11 +1,10 @@
-# Generated from STIXPattern.g4 by ANTLR 4.8
+# Generated from STIXPattern.g4 by ANTLR 4.7.1
 # encoding: utf-8
 from __future__ import print_function
 from antlr4 import *
 from io import StringIO
 import sys
 
-
 def serializedATN():
     with StringIO() as buf:
         buf.write(u"\3\u608b\ua72a\u8133\ub9ed\u417c\u3be7\u7786\u5964\3")
@@ -230,13 +229,12 @@ class STIXPatternParser ( Parser ):
 
     def __init__(self, input, output=sys.stdout):
         super(STIXPatternParser, self).__init__(input, output=output)
-        self.checkVersion("4.8")
+        self.checkVersion("4.7.1")
         self._interp = ParserATNSimulator(self, self.atn, self.decisionsToDFA, self.sharedContextCache)
         self._predicates = None
 
 
 
-
     class PatternContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -288,7 +286,6 @@ class STIXPatternParser ( Parser ):
             self.exitRule()
         return localctx
 
-
     class ObservationExpressionsContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -370,7 +367,6 @@ class STIXPatternParser ( Parser ):
             self.unrollRecursionContexts(_parentctx)
         return localctx
 
-
     class ObservationExpressionOrContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -452,7 +448,6 @@ class STIXPatternParser ( Parser ):
             self.unrollRecursionContexts(_parentctx)
         return localctx
 
-
     class ObservationExpressionAndContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -534,7 +529,6 @@ class STIXPatternParser ( Parser ):
             self.unrollRecursionContexts(_parentctx)
         return localctx
 
-
     class ObservationExpressionContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -789,7 +783,6 @@ class STIXPatternParser ( Parser ):
             self.unrollRecursionContexts(_parentctx)
         return localctx
 
-
     class ComparisonExpressionContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -871,7 +864,6 @@ class STIXPatternParser ( Parser ):
             self.unrollRecursionContexts(_parentctx)
         return localctx
 
-
     class ComparisonExpressionAndContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -953,7 +945,6 @@ class STIXPatternParser ( Parser ):
             self.unrollRecursionContexts(_parentctx)
         return localctx
 
-
     class PropTestContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -1436,7 +1427,6 @@ class STIXPatternParser ( Parser ):
             self.exitRule()
         return localctx
 
-
     class StartStopQualifierContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -1497,7 +1487,6 @@ class STIXPatternParser ( Parser ):
             self.exitRule()
         return localctx
 
-
     class WithinQualifierContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -1562,7 +1551,6 @@ class STIXPatternParser ( Parser ):
             self.exitRule()
         return localctx
 
-
     class RepeatedQualifierContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -1618,7 +1606,6 @@ class STIXPatternParser ( Parser ):
             self.exitRule()
         return localctx
 
-
     class ObjectPathContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -1688,7 +1675,6 @@ class STIXPatternParser ( Parser ):
             self.exitRule()
         return localctx
 
-
     class ObjectTypeContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -1743,7 +1729,6 @@ class STIXPatternParser ( Parser ):
             self.exitRule()
         return localctx
 
-
     class FirstPathComponentContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -1798,7 +1783,6 @@ class STIXPatternParser ( Parser ):
             self.exitRule()
         return localctx
 
-
     class ObjectPathComponentContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -1880,8 +1864,6 @@ class STIXPatternParser ( Parser ):
             super(STIXPatternParser.KeyPathStepContext, self).__init__(parser)
             self.copyFrom(ctx)
 
-        def DOT(self):
-            return self.getToken(STIXPatternParser.DOT, 0)
         def IdentifierWithoutHyphen(self):
             return self.getToken(STIXPatternParser.IdentifierWithoutHyphen, 0)
         def StringLiteral(self):
@@ -1979,7 +1961,6 @@ class STIXPatternParser ( Parser ):
             self.unrollRecursionContexts(_parentctx)
         return localctx
 
-
     class SetLiteralContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -2073,7 +2054,6 @@ class STIXPatternParser ( Parser ):
             self.exitRule()
         return localctx
 
-
     class PrimitiveLiteralContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
@@ -2136,7 +2116,6 @@ class STIXPatternParser ( Parser ):
             self.exitRule()
         return localctx
 
-
     class OrderableLiteralContext(ParserRuleContext):
 
         def __init__(self, parser, parent=None, invokingState=-1):
diff --git a/stix2patterns/v21/grammars/STIXPatternVisitor.py b/stix2patterns/v21/grammars/STIXPatternVisitor.py
index 87423f5..87d7fe1 100644
--- a/stix2patterns/v21/grammars/STIXPatternVisitor.py
+++ b/stix2patterns/v21/grammars/STIXPatternVisitor.py
@@ -1,4 +1,4 @@
-# Generated from STIXPattern.g4 by ANTLR 4.8
+# Generated from STIXPattern.g4 by ANTLR 4.7.1
 from antlr4 import *
 
 # This class defines a complete generic visitor for a parse tree produced by STIXPatternParser.
diff --git a/stix2patterns/v21/pattern.py b/stix2patterns/v21/pattern.py
index 2603739..446e82c 100644
--- a/stix2patterns/v21/pattern.py
+++ b/stix2patterns/v21/pattern.py
@@ -40,15 +40,6 @@ class Pattern(object):
         subclass) instance."""
         antlr4.ParseTreeWalker.DEFAULT.walk(listener, self.__parse_tree)
 
-    def visit(self, visitor):
-        """
-        Walk the parse tree using the given visitor.
-
-        :param visitor: A visitor object (STIXPatternVisitor instance)
-        :return: The visitor's return value
-        """
-        return visitor.visit(self.__parse_tree)
-
     def __do_parse(self, pattern_str):
         """
         Parses the given pattern and returns the antlr parse tree.
diff --git a/stix2patterns/v21/validator.py b/stix2patterns/v21/validator.py
index 12a1a38..11adaed 100644
--- a/stix2patterns/v21/validator.py
+++ b/stix2patterns/v21/validator.py
@@ -2,14 +2,69 @@
 Validates a user entered pattern against STIXPattern grammar.
 """
 
+import enum
+
 from antlr4 import CommonTokenStream, ParseTreeWalker
 
 from . import object_validator
 from ..exceptions import STIXPatternErrorListener
 from .grammars.STIXPatternLexer import STIXPatternLexer
+from .grammars.STIXPatternListener import STIXPatternListener
 from .grammars.STIXPatternParser import STIXPatternParser
 from .inspector import InspectionListener
 
+QualType = enum.Enum("QualType", "WITHIN REPEATS STARTSTOP")
+
+
+class DuplicateQualifierTypeError(Exception):
+    """
+    Instances represent finding multiple qualifiers of the same type directly
+    applied to an observation expression (i.e. not on some parenthesized group
+    of which the observation expression is a member).
+    """
+    def __init__(self, qual_type):
+        """
+        Initialize this exception instance.
+
+        :param qual_type: The qualifier type which was found to be duplicated.
+            Must be a member of the QualType enum.
+        """
+        message = "Duplicate qualifier type encountered: " + qual_type.name
+
+        super(DuplicateQualifierTypeError, self).__init__(message)
+
+        self.qual_type = qual_type
+
+
+class ValidationListener(STIXPatternListener):
+    """
+    Does some pattern validation via a parse tree traversal.
+    """
+    def __init__(self):
+        self.__qual_types = None
+
+    def __check_qualifier_type(self, qual_type):
+        if self.__qual_types is not None:
+            if qual_type in self.__qual_types:
+                raise DuplicateQualifierTypeError(qual_type)
+            else:
+                self.__qual_types.add(qual_type)
+
+    def exitObservationExpressionSimple(self, ctx):
+        self.__qual_types = set()
+
+    def exitObservationExpressionCompound(self, ctx):
+        self.__qual_types = None
+
+    def exitObservationExpressionWithin(self, ctx):
+        self.__check_qualifier_type(QualType.WITHIN)
+
+    def exitObservationExpressionRepeated(self, ctx):
+        self.__check_qualifier_type(QualType.REPEATS)
+
+    def exitObservationExpressionStartStop(self, ctx):
+        self.__check_qualifier_type(QualType.STARTSTOP)
+
 
 def run_validator(pattern, start):
     """
@@ -38,7 +93,6 @@ def run_validator(pattern, start):
             parser.literalNames[i] = parser.symbolicNames[i]
 
     tree = parser.pattern()
-    inspection_listener = InspectionListener()
 
     # replace with easier-to-understand error message
     if not (start[0] == '[' or start == '(['):
@@ -47,6 +101,7 @@ def run_validator(pattern, start):
 
     # validate observed objects
     if len(parseErrListener.err_strings) == 0:
+        inspection_listener = InspectionListener()
         ParseTreeWalker.DEFAULT.walk(inspection_listener, tree)
         patt_data = inspection_listener.pattern_data()
 
@@ -56,9 +111,9 @@ def run_validator(pattern, start):
             parseErrListener.err_strings.extend(obj_validator_results)
 
         # check qualifiers
-        qualifiers = [q.split()[0] for q in patt_data.qualifiers]
-        if len(qualifiers) != len(set(qualifiers)):
-            parseErrListener.err_strings.insert(0, "FAIL: The same qualifier is"
-                                                   " used more than once")
+        try:
+            ParseTreeWalker.DEFAULT.walk(ValidationListener(), tree)
+        except DuplicateQualifierTypeError as e:
+            parseErrListener.err_strings.insert(0, "FAIL: " + e.args[0])
 
     return parseErrListener.err_strings


Test patch:
diff --git a/stix2patterns/test/v21/test_validator.py b/stix2patterns/test/v21/test_validator.py
index d78fc75..26f9076 100644
--- a/stix2patterns/test/v21/test_validator.py
+++ b/stix2patterns/test/v21/test_validator.py
@@ -51,7 +51,13 @@ FAIL_CASES = [
     ("[file:hashes.'SHA-256' = 'f00']",  # Malformed hash value
         "FAIL: 'f00' is not a valid SHA-256 hash"),
     ("[win-registry-key:key = 'hkey_local_machine\\\\foo\\\\bar'] WITHIN 5 SECONDS WITHIN 6 SECONDS",
-        "FAIL: The same qualifier is used more than once"),
+        "FAIL: Duplicate qualifier type encountered: WITHIN"),
+    ("([win-registry-key:key = 'hkey_local_machine\\\\foo\\\\bar'] REPEATS 2 TIMES REPEATS 3 TIMES)",
+        "FAIL: Duplicate qualifier type encountered: REPEATS"),
+    ("[win-registry-key:key = 'hkey_local_machine\\\\foo\\\\bar'] "
+     "START t'2016-06-01T01:30:00.123Z' STOP t'2016-06-01T02:20:00.123Z' "
+     "START t'2016-06-01T01:30:00.123Z' STOP t'2016-06-01T02:20:00.123Z'",
+        "FAIL: Duplicate qualifier type encountered: STARTSTOP"),
     # TODO: add more failing test cases.
 ]
 
@@ -89,6 +95,12 @@ PASS_CASES = [
     "[foo:bar=1] REPEATS 9 TIMES",
     "[network-traffic:start = '2018-04-20T12:36:24.558Z']",
     "( [(network-traffic:dst_port IN(443,6443,8443) AND network-traffic:src_packets != 0) ])",  # Misplaced whitespace
+    "([win-registry-key:key = 'hkey_local_machine\\\\foo\\\\bar']) WITHIN 5 SECONDS WITHIN 6 SECONDS",
+    "([win-registry-key:key = 'hkey_local_machine\\\\foo\\\\bar'] REPEATS 2 TIMES) REPEATS 2 TIMES",
+    "[network-traffic:src_port = 37020 AND user-account:user_id = 'root'] "
+    "START t'2016-06-01T01:30:00.123Z' STOP t'2016-06-01T02:20:00.123Z' OR "
+    "[ipv4-addr:value = '192.168.122.83'] "
+    "START t'2016-06-01T03:55:00.123Z' STOP t'2016-06-01T04:30:24.743Z'"
 ]