Skip to main content

5 Tipps zur Verwendung der Rego-Sprache für Open Policy Agent (OPA)

header cloud security

18. September 2020

0 Min. Lesezeit

Bei Fugue sind wir große Fans von Open Policy Agent (OPA) und haben viel Rego-Code geschrieben, um Cloud-Ressourcen zu schützen. Deshalb haben wir die wertvollsten Erkenntnisse aus unserer Arbeit zusammengetragen. Mit OPA und der Rego-Sprache können Sie Policy as Code nutzen, um Richtlinien automatisch durchzusetzen.

1. Die Gesetze von De Morgan sind Ihre neuen besten Freunde

Wenn ich diese Liste auf einen einzigen Punkt beschränken müsste, wäre es dieser. Die Gesetze von De Morgan sind zwei Umformungsregeln. Auf Rego angewendet, ermöglichen sie Ihnen, eine Regel umzuformen und dabei ihr Verhalten exakt beizubehalten.

Sie werden üblicherweise so geschrieben:

¬(A ∧ B) ⇔ ¬A ∨ ¬B
¬(A ∨ B) ⇔ ¬A ∧ ¬B

Oder als Code:

!(a && b) <=> !a || !b
!(a || b) <=> !a && !b

Ein Beispiel macht es vielleicht verständlicher. Die folgenden beiden Aussagen sind gleichbedeutend und veranschaulichen die Anwendung des ersten Gesetzes:

  • Das ist keine Pizza mit Schinken und Pilzen.

  • Auf dieser Pizza ist kein Schinken oder es sind keine Pilze.

Mit ihnen können Sie UND-Verknüpfungen in ODER-Verknüpfungen umwandeln und umgekehrt, ohne das Verhalten des Codes zu verändern. Moment mal – könnten Sie jetzt fragen: Wenn diese Umformungen das Verhalten meines Codes nicht verändern, wozu sind sie dann gut?

Der entscheidende Punkt ist, dass Rego als Abfragesprache stark auf Disjunktionen (also ODER-Aussagen) ausgerichtet ist. Zum Beispiel lässt sich prüfen, ob jemand in der Gruppe qualifiziert ist, eine Pizza zu schneiden:

default allow = false

allow {
  input.people[_].profession == "mathematician"
}

Wie prüfen wir aber, ob alle Personen in der Gruppe qualifiziert sind, Pizzen zu schneiden? Könnten wir, ähm, zuerst eine Gruppe mit den qualifizierten Personen erstellen und dann deren Anzahl überprüfen?

qualified_pizza_cutters[name] = person {
  person = input.people[name]
  person.profession == "mathematician"
}

default allow = false

allow {
  count(qualified_pizza_cutters) == count(input.people)
}

Das ist nicht ideal. Abgesehen davon, dass die Aussage schwer zu lesen ist, lässt sie sich nur schwer durch einen Abfrageplaner optimieren, da die Mengen gezählt werden müssen – obwohl wir bereits abbrechen könnten, sobald wir eine minderjährige Person in der Gruppe finden.

Hier kommen die Gesetze von De Morgan ins Spiel: Sie zeigen uns, dass wir Abfragen immer als Disjunktionen schreiben können, indem wir einige Teile negieren. Dazu führen wir einfach eine Hilfsaussage ein, die die ursprüngliche Aussage negiert:

cannot_cut_pizza {
  input.people[_].profession != "mathematician"
}

Dann ist es einfach, die Richtlinie zu schreiben:

default allow = false

allow {
  not cannot_cut_pizza
}

Und fertig! Effizient und leicht verständlich.

Fazit: Wenn eine Rego-Richtlinie schwer zu schreiben scheint, prüfen Sie immer, ob sich die negierte Richtlinie leichter formulieren lässt, und negieren Sie sie anschließend wieder! Logik ist toll!

2. or oder any?

Wie wir gerade gesehen haben, lässt sich Rego leichter lesen und schreiben, wenn die ODER-Aussagen ganz oben stehen und die UND-Aussagen ordentlich nacheinander in den Regelkörpern angeordnet sind. Doch als Programmiererinnen und Programmierer stoßen wir meist schnell auf einen Fall, in dem die idiomatische Vorgehensweise nicht gut funktioniert.

In Rego kommt das häufig vor, wenn ein Regelkörper bereits etliche Abfragen enthält, zum Beispiel:

probably_a_pizza {
  input.shape == 'circle'
  input.ingredients[_] == 'cheese'
  input.ingredients[_] == 'tomato_sauce'
}

Was aber, wenn wir auch Pizzastücke zulassen möchten? Wir könnten den gesamten Regelkörper duplizieren:

probably_a_pizza {
  input.shape == 'circle'
  input.ingredients[_] == 'cheese'
  input.ingredients[_] == 'tomato_sauce'
} {
  input.shape == 'circular_sector'
  input.ingredients[_] == 'cheese'
  input.ingredients[_] == 'tomato_sauce'
}

Das ist nicht ideal. In den meisten Fällen können wir eine Hilfsregel einführen:

has_a_pizza_shape {
  input.shape == 'circle'
} {
  input.shape == 'circular_sector'
}

probably_a_pizza {
  has_a_pizza_shape
  input.ingredients[_] == 'cheese'
  input.ingredients[_] == 'tomato_sauce'
}

Das funktioniert in diesem Fall gut. Manchmal haben wir jedoch so viele Hilfsregeln, dass es schwierig wird, überhaupt passende Namen dafür zu finden! In den seltenen Fällen können wir einfach so tun, als gäbe es in Rego eine ODER-Anweisung namens any:

probably_a_pizza {
  any([input.shape == 'circle', input.shape == 'circular_sector'])
  input.ingredients[_] == 'cheese'
  input.ingredients[_] == 'tomato_sauce'
}

So lässt sich das gut lesbar schreiben. In Maßen verwendet, kann jede beliebige Option Ihnen das Leben erheblich erleichtern.

3. Comprehensions mit any und all

Bleiben wir bei diesem Ansatz: any und all sind auch an vielen anderen Stellen äußerst nützlich, insbesondere bei List-Comprehensions. Diese beiden Funktionen ergänzen sich so gut, dass das Ganze mehr ist als die Summe seiner Teile.

Kehren wir zum ersten Beispiel zurück: So können wir all zusammen mit einer List-Comprehension verwenden, um dieselbe Richtlinie auszudrücken:

allow {
  all([person.profession == "mathematician" | person = input.people[_]])
}

Ich finde, diese Variante ist genauso gut lesbar wie die Version, die wir mithilfe der Gesetze von De Morgan erstellt haben. Welche Variante idiomatischer ist, hängt meist vom Kontext ab.

  1. Lässt sich die negierte Richtlinie leichter nachvollziehen? Dann verwenden wir die Umformung nach De Morgan.

  2. Oder ist es einfacher, die Dinge, die wir überprüfen wollen, als Liste oder Sammlung zu betrachten? Dann ist eine List-Comprehension passend.

4. Mit walk ganze Dokumentbäume durchsuchen

Rego-Abfragen werden immer beendet – das ist gut, hat aber seinen Preis: Rekursive Regeln lassen sich im Allgemeinen nicht schreiben.

Warum brauchen wir rekursive Regeln? Sie sind sehr nützlich, wenn es um rekursive Eingabedokumente geht, etwa tief verschachtelte Bäume. Zum Glück bietet OPA einige integrierte Funktionen, die dabei helfen.

Zum Beispiel könnten wir eine Richtlinie schreiben, um Diskussionsthreads zu überwachen:

[
  {
    "author": "Alice",
    "message": "I think New York is at least as good as Italy for pizza",
    "replies": [
      {
        "author": "Bob",
        "message": "It's because of the water!",
        "replies": [
          {
            "Author": "Alice",
            "message": "I've heard that before but I'm not convinced.",
            "replies": []
          }
        ]
      },
    ]
  },
  {
    "author": "Charlie",
    "message": "You are a horrible person.",
    "replies": []
  }
]

Wie rufen wir die Nachrichten in Rego ab? Wir könnten es mit Folgendem versuchen:

messages[message] {
  message = input[_].message
} {
  message = input[_].replies[_].message
}
  message = input[_].replies[_].replies[_].message
}

Bei endlich vielen Verschachtelungsebenen funktioniert das gut. Darauf können wir uns aber nicht immer verlassen, und außerdem ist der Code nicht besonders leicht zu lesen. Stattdessen können Sie mit walk den Baum rekursiv durchlaufen und jedes Feld message abrufen:

messages[message] {
  [_, value] := walk(walk_input)
  message = value.message
}

Sobald wir diese Nachrichten in einer regulären Rego-Regel haben, können wir eine idiomatische Richtlinie schreiben, die Diskussionsthreads mit Schimpfwörtern zurückweist.

deny {
  swear_words := {"hawaii", "pineapple"}
  contains(lower(messages[_]), swear_words[_])
}

Ein Beispiel aus der Praxis sind untergeordnete Terraform-Module: Hier verwenden wir walk in Regula, um die untergeordneten Module abzuflachen – ganz ähnlich wie die Nachrichten im Diskussionsthread.

5. Pakete und Regeln dynamisch laden

Ein interessanter Aspekt des Rego-Designs ist, dass das gesamte „Universum“ aus Regeln und Daten unter demselben Dokument verschachtelt ist. Ob Sie auf Benutzereingaben, Daten aus JSON- oder YAML-Dateien oder Regeln aus Ihren Paketen zugreifen – es sind immer einfach Referenzen:

input.people[0].order
data.recipes.pizza.pepperoni
data.policies.strict.allow

Da wir in Rego mithilfe von Variablen oder Platzhaltern alle Referenzen durchlaufen können, können wir die geladenen Pakete dynamisch ermitteln. So lässt sich eine Architektur aufbauen, bei der Sie einfach neue Richtliniendateien hinzufügen und sie alle in einem Bericht zusammenfassen.

Wenn Sie die Richtlinien in separate Pakete legen, lassen sie sich außerdem einzeln debuggen, und es besteht keine Gefahr, dass sich Regelnamen überschneiden. Wir legen alle Richtlinien im Namespace data.policies ab. Die erste verwendet die Gesetze von De Morgan, um zu prüfen, ob sich Käse auf einer Pizza befindet:

package policies.cheese

contains_cheese {
  input.toppings[_] == "cheese"
}

deny["must contain cheese"] {
  not contains_cheese
}

Die zweite Richtlinie stellt sicher, dass zwei Personen sich nicht wegen des letzten Stücks in die Haare geraten:

package policies.slices

deny["must be able to share with two people"] {
  input.slices % 2 != 0
}

Sobald wir uns auf einen Namespace für unsere Richtlinien geeinigt haben, können wir sie relativ einfach zusammenfassen: Wir rufen jedes deny in data.policies ab und geben einen für Menschen verständlichen Bericht formatiert aus.

package summary

report = msg {
  denies := {m | m := data.policies[_].deny[_]}
  msg := sprintf("%d policies failed\n%s", [
    count(denies),
    concat("\n", [sprintf("- %s", [m]) | denies[m]]),
  ])
}

Zusammen mit etwas jq, um die tatsächlichen Werte zu extrahieren, lässt sich direkt in Ihrem Terminal ein übersichtlicher Bericht erstellen. Hier verwenden wir -I, um die Eingabe aus stdin zu lesen.

$ opa eval -I 'data.summary.report' -d . --format json | \
    jq -r '.result | .[] | .expressions | .[] | .value'
{"toppings": ["crab", "tomato sauce"], "slices": 6}
1 policies failed
- must contain cheese

Pakete als Daten zu verwenden, eröffnet viele weitere Möglichkeiten für fortgeschrittene Tricks – zum Beispiel können Sie input in ein anderes Format umwandeln, je nachdem, ob ein Paket input_version = 1 oder input_version = 2 deklariert. Und das alles in Rego!

Weiterlesen

feature customer snowflake
Article

Sicherheit von Anfang an: Die Snyk Studio-Integration für Snowflake Cortex Code

Snyk Studio ist in Snowflake Cortex Code integriert und scannt KI-generierten Code, Abhängigkeiten und Container während der Entwicklung auf Schwachstellen.

Blog

Stadium-Sommer: Die Snyk Connect Fan Zone Tour

Die Fan Zone Tour von Snyk brachte KI-Security-Workshops, Networking und freundschaftlichen Wettbewerb in 8 Städte und 3 virtuelle Sessions. Die Teilnehmenden bauten ihre Kompetenzen aus, tauschten Ideen aus und entwickelten sich gemeinsam weiter.

Blog

Snyk VulnBench JS 1.0: Finden LLMs zweimal dieselben Fehler?

Snyk VulnBench JS 1.0: 300 wiederholte Scans zeigen, dass LLM-Sicherheitsergebnisse von Durchlauf zu Durchlauf variieren, während SAST und Modelle unterschiedliche Sicherheitslücken aufdecken.