Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 1 addition & 19 deletions spec/OPEN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
33 changes: 24 additions & 9 deletions spec/check_grammar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -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


Expand Down
47 changes: 24 additions & 23 deletions spec/sentences.abnf
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand All @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down
Loading