diff --git a/spec/OPEN.md b/spec/OPEN.md index bf512bd..467b6c2 100644 --- a/spec/OPEN.md +++ b/spec/OPEN.md @@ -83,22 +83,4 @@ Every rule needs a case, apart from the ones listed here. The `sentence` rules h ## Sentences the ABNF and ergo.rego read differently -`check_grammar.py` reads sentences with `ergo.rego` and with a parser built from `sentences.abnf`, and fails on any sentence that one reads and the other rejects as a matter of syntax. The ones below are known, each with the sentence that shows it, and the script also fails when one of them stops disagreeing. None is decided yet. - -Whitespace around a sentence. `ergo.rego` trims it, as YAML would leave a trailing newline, and the ABNF has no room for it: - -- check `" x is 1"` -- check `"x is 1 "` - -Spaces inside a keyword that has more than one word. [sentences.md](sentences.md) says a run of spaces between two words reads as one, `ergo.rego` does that, and the ABNF spells `is not empty`, `if any,` and `named by` with one space each: - -- check `"x is not empty"` -- check `"every x, if any, is 1"` -- check `"x named by y is 1"` - -An elapsed time in a `whose` or after a `plus`. `ergo.rego` reads it and then reports that it isn't supported yet, as [sentence.derived.elapsed](sentences.md#derived-values) says, and the ABNF lets an elapsed time stand alone and nowhere else: - -- check `"x is 1 plus days elapsed from p to q"` -- check `"every xs whose days elapsed from p to q is 1 is not empty"` -- for-line `"every xs whose days elapsed from p to q is 1 as x"` -- from-line `"vulns whose days elapsed from started to ended is 1"` +`check_grammar.py` reads sentences with `ergo.rego` and with a parser built from `sentences.abnf`, and fails on any sentence that one reads and the other rejects as a matter of syntax. A disagreement goes here, as a line like ``- check `"x is 1"` ``, with the sentence written as JSON, until it's decided. The script also fails when a listed one stops disagreeing. None is listed now. diff --git a/spec/check_grammar.py b/spec/check_grammar.py index eb00237..0646a24 100644 --- a/spec/check_grammar.py +++ b/spec/check_grammar.py @@ -26,11 +26,10 @@ ] VOCABULARY = ["x", "y", "items", "status", "a_b", "k-1", "_id", "Count", "env", "sha", "pr", "commits", "owner"] -GRAMMAR_WORDS = [ - "a", "after", "an", "and", "as", "at", "before", "between", "count", "days", "each", "empty", "equal", "every", - "false", "first", "hours", "in", "item", "last", "less", "minutes", "more", "named", "not", "null", "of", "on", - "one", "or", "plus", "seconds", "some", "sum", "to", "true", "weeks", "whose", "within", -] +GRAMMAR_WORDS = re.findall( + r"`([a-z]+)`", + re.search(r"^\*\*\[sentence\.words\]\*\* [^:]*: (.*?)\. Without", open("spec/sentences.md").read(), re.M).group(1), +) ASCII_POOL = [chr(c) for c in range(0x20, 0x7F) if chr(c) != "`"] OTHER_POOL = [0xE9, 0x65E5, 0x1F600, 0xFEFF, 0x2028, 0xA0] @@ -200,17 +199,33 @@ def walk(value, key=None): for path in sorted(glob.glob("spec/cases/*/cases.json")): for group in json.load(open(path)): - walk(group.get("policy", {})) + policy = group.get("policy", {}) + walk(policy) + requirements = policy.get("requirements") + for requirement in requirements.values() if isinstance(requirements, dict) else []: + checks = requirement.get("checks") if isinstance(requirement, dict) else None + for check in checks.values() if isinstance(checks, dict) else []: + if isinstance(check, str): + found.append(("check", check)) return found +def bare_word_keys(node): + if node.name in ("first-key", "plain-right") and node.children and node.children[0].name == "bare-key": + if node.children[0].value in GRAMMAR_WORDS: + yield node.children[0].value + for child in node.children: + yield from bare_word_keys(child) + + def abnf_accepts(grammar, rules, text): for rule in rules: try: - grammar(rule).parse_all(text) - return True + tree = grammar(rule).parse_all(text.strip(" ")) except ParseError: - pass + continue + if not any(bare_word_keys(tree)): + return True return False diff --git a/spec/sentences.abnf b/spec/sentences.abnf index 514836f..dcaa4fb 100644 --- a/spec/sentences.abnf +++ b/spec/sentences.abnf @@ -2,6 +2,7 @@ ; sentences.md says what each form means and what it can't say here. ; Every quoted string is case-sensitive, as if written with RFC 7405's %s. ; WS between two words is one space when the report prints a sentence. +; Spaces at either end of a line are dropped before it's read. ; A check is one assertion, or several joined with "and". Under assert:, ; a YAML list holds one assertion per item and means the same. @@ -11,7 +12,7 @@ assertion = plain / quantified / derived-assertion / elapsed-assertion plain = path WS phrase quantified = quantifier WS path [WS whose] [if-any] WS phrase quantifier = "every" / "some" -if-any = "," WS "if any," +if-any = "," WS "if" WS "any," derived-assertion = derived-left WS compare-phrase elapsed-assertion = elapsed WS compare-phrase @@ -21,21 +22,21 @@ for-item = quantifier WS path [WS whose] [if-any] WS "as" WS name name = bare-key ; A subject's from: line. -from-line = path [WS "named by each of" WS right-path] [WS whose] +from-line = path [WS "named" WS "by" WS "each" WS "of" WS right-path] [WS whose] ; whose keeps the items that pass every condition. whose = "whose" WS condition *(WS "and" WS condition) -condition = path WS phrase / derived WS compare-phrase +condition = path WS phrase / (derived / elapsed) WS compare-phrase ; Derived values: count of, sum of, and terms added with plus. ; Only the last term on the left can carry a whose, and none on the right. derived = ("count" / "sum") WS "of" WS path -; An elapsed time stands alone: it takes no whose and joins no plus. +; ergo.rego doesn't run an elapsed time in a whose or after a plus yet. elapsed = ("seconds" / "minutes" / "hours" / "days" / "weeks") WS "elapsed" WS "from" WS path WS "to" WS path -term = right-path / derived / number +term = right-path / derived / elapsed / number derived-left = derived [WS whose] / 1*(term WS "plus" WS) (term / derived WS whose) -field = (right-path / derived) *(WS "plus" WS term) +field = (right-path / derived / elapsed) *(WS "plus" WS term) / number 1*(WS "plus" WS term) operand = value / field @@ -45,25 +46,25 @@ phrase = one-of-phrase / exists-phrase / empty-phrase / type-phrase / starts-phrase / time-phrase / compare-phrase compare-phrase = within-phrase / order-phrase / between-phrase / is-phrase -is-phrase = ("is not" / "does not equal") WS operand - / ("is equal to" / "equals" / "is") WS operand -within-phrase = ("is equal to" / "equals" / "is") WS operand WS "within" WS number -order-phrase = ("is at least" / "is at most" / "is more than" / "is less than") WS operand -between-phrase = "is between" WS number WS "and" WS number -one-of-phrase = ("is one of" / "is in") WS list - / ("is not one of" / "is not in") WS list -exists-phrase = "exists" / "does not exist" -empty-phrase = "is empty" / "is not empty" -type-phrase = "is a non-empty string" / "is" WS ("a" / "an") WS type +is-phrase = ("is" WS "not" / "does" WS "not" WS "equal") WS operand + / ("is" WS "equal" WS "to" / "equals" / "is") WS operand +within-phrase = ("is" WS "equal" WS "to" / "equals" / "is") WS operand WS "within" WS number +order-phrase = ("is" WS "at" WS "least" / "is" WS "at" WS "most" / "is" WS "more" WS "than" / "is" WS "less" WS "than") WS operand +between-phrase = "is" WS "between" WS number WS "and" WS number +one-of-phrase = ("is" WS "one" WS "of" / "is" WS "in") WS list + / ("is" WS "not" WS "one" WS "of" / "is" WS "not" WS "in") WS list +exists-phrase = "exists" / "does" WS "not" WS "exist" +empty-phrase = "is" WS "empty" / "is" WS "not" WS "empty" +type-phrase = "is" WS "a" WS "non-empty" WS "string" / "is" WS ("a" / "an") WS type type = "list" / "string" / "number" / "boolean" / "object" -contains-phrase = ("contains" / "does not contain") WS operand -contains-all-phrase = ("contains all of" / "contains none of") WS list -matches-phrase = ("matches" / "does not match") WS list -starts-phrase = ("starts with" / "ends with") WS operand -time-phrase = ("is before" / "is after" / "is on or after" / "is on or before") WS operand +contains-phrase = ("contains" / "does" WS "not" WS "contain") WS operand +contains-all-phrase = ("contains" WS "all" WS "of" / "contains" WS "none" WS "of") WS list +matches-phrase = ("matches" / "does" WS "not" WS "match") WS list +starts-phrase = ("starts" WS "with" / "ends" WS "with") WS operand +time-phrase = ("is" WS "before" / "is" WS "after" / "is" WS "on" WS "or" WS "after" / "is" WS "on" WS "or" WS "before") WS operand list = values / right-path -values = value *("," WS value) +values = value *("," [WS] value) value = string / number / "true" / "false" / "null" ; Paths. The first key of a path, when bare, is never a word of the @@ -81,7 +82,7 @@ position = "item" WS positive / "first" / "last" positive = %x31-39 *DIGIT start = "$params" / "$input" / first-key first-key = bare-key / "." key / quoted-key -step = "." key / WS "named by" WS reference +step = "." key / WS "named" WS "by" WS reference / "[" reference "]" / "[" quoted-key "]" / "[" int "]" key = bare-key / quoted-key reference = ("$params" / "$input") "." key / name