Skip to content
Draft
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
117 changes: 117 additions & 0 deletions tool-scubagoggles-gws/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# `scubagoggles` (Google Workspace config)

A **tool integration example** showing how to attest a Google Workspace
tenant's configuration with CISA [ScubaGoggles](https://github.com/cisagov/ScubaGoggles)
under cilock, using rookery's native `scubagoggles` attestor (predicate type
`https://aflock.ai/attestations/scubagoggles/v0.1`), then gate it with our own
Rego policy.

## Facts, not a verdict

This example's defining choice: the attestor signs the **raw provider
configuration** ScubaGoggles collects — not ScubaGoggles' own Pass/Fail
results. ScubaGoggles bundles OPA to run CISA's baselines; cilock ignores that
verdict and captures only the configuration (the `Raw` section of
`ScubaResults*.json`, which is byte-for-byte what ScubaGoggles feeds to OPA).
The compliance decision lives in **your** policy.

| Antipattern | Correct shape (this example) |
|---|---|
| Attest ScubaGoggles' `Results` (per-control Pass/Fail) | Attest the `Raw` provider config — the actual settings |
| Verdict baked into the evidence | Evidence is reusable ground truth; your policy decides |
| Rego sees pre-decided results | Rego sees `input.predicate.config` = the real settings |

## Validated invocation

```bash
cilock run --step gws-assessment \
--signer-file-key-path key.pem \
--outfile attestation.json \
--attestations scubagoggles,environment \
--enable-archivista=false \
-- scubagoggles gws -b commoncontrols -c credentials.json -o ./out --quiet
```

ScubaGoggles is a Python package (`pip install scubagoggles`), needs OPA
(`scubagoggles getopa`), and authenticates to Google with an OAuth client
(`-c credentials.json`, one-time super-admin browser sign-in; token cached for
headless reruns) or a domain-wide-delegation service account
(`--subjectemail`). cilock **records** the output; it does not authenticate for
you, and all scopes are read-only. See [`reproduce.sh`](./reproduce.sh).

## What gets captured

`scubagoggles,environment` (plus the always-on `product`/`material`) yields:

- `command-run/v0.1` — the literal `scubagoggles` argv and exit code.
- `material/v0.3`, `product/v0.3` — Merkle hashes of inputs and the
`ScubaResults*.json` written.
- `scubagoggles/v0.1` — the raw GWS provider config (`policies`,
`super_admins`, `domains`, OU layout, …). Subjects:
`googleworkspace:tenant:<customerId>`, `:domain:<domain>`, `:orgunit:<path>`.
- `environment/v0.1` — host facts.

## The policy

The `policy/` directory holds TestifySec-authored, deny-based rego that reads
`input.predicate.config` directly. It is **our** rego — CISA's baselines read
`input.policies` and emit a `tests` set, which doesn't fit the policyverify
`deny[]` contract, so we re-expressed the control intent (informed by CISA's
SCuBA baselines, CC0-1.0). Three baselines are covered:

| File | GWS baseline | Checks |
|---|---|---|
| `gws_commoncontrols.rego` | Common Controls | phishing-resistant MFA (1.1), 12h session cap (4.1), 2–8 super-admins (6.2), 2SV enforcement |
| `gws_gmail.rego` | Gmail | DMARC enforcing (not `p=none`), SPF + DKIM published, anomalous-attachment protection |
| `gws_drive.rego` | Drive & Docs | external sharing not unrestricted, no web publishing |

**NIST 800-171 coverage.** These baselines were chosen to give a CUI assessment
the evidence it needs across the families that map to Google Workspace config:
Access Control (3.1 — least privilege, session, CUI sharing), Identification &
Authentication (3.5 — MFA, password policy), and System & Communications
Protection (3.13 — email authenticity/anti-spoofing). The precise
GWS-control → 800-171-requirement mapping is the platform's job; this policy
only checks GWS controls and references their GWS ids.

Unit tests: `opa test policy/` (15 cases).

## Full cycle: create → verify

[`reproduce.sh`](./reproduce.sh) runs the whole loop through cilock: it
**creates** the attestation (`cilock run`), builds a witness policy that trusts
the run's signer and embeds the rego, **signs** it (`cilock sign`), and
**verifies** the attestation against it (`cilock verify -s sha256:<domain>`).
`policyverify` evaluates our rego inside the signature-verified envelope, so the
verdict is bound to trusted evidence.

A non-compliant tenant **denies** — the gate blocks, e.g.:

```
collection rejected: gws-assessment … rego policy evaluation failed …: policy was denied due to:
… allows non-phishing-resistant 2SV factor "NO_TELEPHONY" … (GWS.COMMONCONTROLS.1.1),
… web-session duration 72000s exceeds the 12h/43200s maximum (GWS.COMMONCONTROLS.4.1),
super-admin count 1 is below the required minimum of 2 (GWS.COMMONCONTROLS.6.2)
```

A compliant tenant verifies clean (exit 0) and the gate allows.

## Try it without a tenant

`raw/sample-provider-input.json` is **synthetic** (no real tenant — see the
privacy note below) and deliberately non-compliant, so you can see the policy
fire offline:

```bash
opa eval -d policy/gws_commoncontrols.rego -i raw/sample-provider-input.json \
'data.gws_commoncontrols.deny' --format pretty
```

Expected: three denials — non-phishing-resistant 2SV factor, a 20h session, and
a single super-admin.

## Privacy

Unlike the cloud-scanner examples, a real run here captures a tenant's live
security configuration (admin accounts, OU layout, DNS/auth policy). **No real
tenant data is committed** to this public repo — `raw/` holds only synthetic
example.org data. Keep your own `attestation.json` out of version control.
70 changes: 70 additions & 0 deletions tool-scubagoggles-gws/policy/gws_commoncontrols.rego
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# gws_commoncontrols.rego
#
# TestifySec-authored Google Workspace "Common Controls" policy. Evaluates the
# RAW provider configuration captured by the `scubagoggles` attestor
# (input.predicate.config) and emits deny[] for the policyverify rego engine.
#
# This is OUR rego, written against the scubagoggles attestor predicate and the
# policyverify deny[] contract — NOT a copy of CISA's. The control intent is
# informed by CISA's SCuBA GWS Common Controls baseline
# (https://github.com/cisagov/ScubaGoggles, CC0-1.0); the rego is original.
#
# Why our own rego: CISA's baselines read input.policies at the top level and
# emit a `tests` set; policyverify feeds input.predicate.config and queries
# <package>.deny on every module, so consuming CISA's rego unchanged would mean
# forking it (input-path rewrite + per-module deny rules). Purpose-built deny
# rules over our predicate sidestep that and keep the verdict in our trust
# domain.

package gws_commoncontrols

import future.keywords.contains
import future.keywords.if
import future.keywords.in

config := input.predicate.config

# Container (OU/group) settings maps, e.g. config.policies["TestifySec, Inc."].
containers := config.policies

# GWS.COMMONCONTROLS.6.2 — a minimum of 2 and a maximum of 8 distinct
# super-admins SHALL be configured.
deny contains msg if {
n := count(config.super_admins)
n < 2
msg := sprintf("super-admin count %d is below the required minimum of 2 (GWS.COMMONCONTROLS.6.2)", [n])
}

deny contains msg if {
n := count(config.super_admins)
n > 8
msg := sprintf("super-admin count %d exceeds the maximum of 8 (GWS.COMMONCONTROLS.6.2)", [n])
}

# GWS.COMMONCONTROLS.1.1 — phishing-resistant MFA: the allowed 2SV sign-in
# factor set SHALL be hardware security keys only (ONLY_SECURITY_KEY).
deny contains msg if {
some name, settings in containers
factor := settings.security_two_step_verification_enforcement_factor.allowedSignInFactorSet
factor != "ONLY_SECURITY_KEY"
msg := sprintf("%q allows non-phishing-resistant 2SV factor %q; require ONLY_SECURITY_KEY (GWS.COMMONCONTROLS.1.1)", [name, factor])
}

# GWS.COMMONCONTROLS.4.1 — users SHALL be forced to re-authenticate after a
# web session no longer than 12 hours (43200 seconds).
deny contains msg if {
some name, settings in containers
dur := settings.security_session_controls.webSessionDuration
seconds := to_number(trim_suffix(dur, "s"))
seconds > 43200
msg := sprintf("%q web-session duration %s exceeds the 12h/43200s maximum (GWS.COMMONCONTROLS.4.1)", [name, dur])
}

# GWS.COMMONCONTROLS.1.x — 2-step verification SHALL be enforced. Deny any
# container that exposes the enforcement setting but leaves enforcedFrom unset.
deny contains msg if {
some name, settings in containers
enforcement := settings.security_two_step_verification_enforcement
enforcement.enforcedFrom == ""
msg := sprintf("%q does not enforce 2-step verification (enforcedFrom is empty) (GWS.COMMONCONTROLS.1)", [name])
}
51 changes: 51 additions & 0 deletions tool-scubagoggles-gws/policy/gws_commoncontrols_test.rego
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Unit tests for gws_commoncontrols.rego. Run with: opa test policy/
package gws_commoncontrols

import future.keywords.if
import future.keywords.in

# A fully compliant tenant: 2 super-admins, hardware-key 2SV, 12h session,
# enforcement on.
compliant := {"predicate": {"config": {
"super_admins": [{"primaryEmail": "a@x"}, {"primaryEmail": "b@x"}],
"policies": {"Root": {
"security_two_step_verification_enforcement_factor": {"allowedSignInFactorSet": "ONLY_SECURITY_KEY"},
"security_session_controls": {"webSessionDuration": "43200s"},
"security_two_step_verification_enforcement": {"enforcedFrom": "2024-01-01T00:00:00Z"},
}},
}}}

test_compliant_has_no_denials if {
count(deny) == 0 with input as compliant
}

test_too_few_super_admins if {
cfg := json.patch(compliant, [{"op": "replace", "path": "/predicate/config/super_admins", "value": [{"primaryEmail": "only@x"}]}])
some msg in deny with input as cfg
contains(msg, "below the required minimum of 2")
}

test_too_many_super_admins if {
admins := [{"primaryEmail": sprintf("a%d@x", [i])} | some i in numbers.range(1, 9)]
cfg := json.patch(compliant, [{"op": "replace", "path": "/predicate/config/super_admins", "value": admins}])
some msg in deny with input as cfg
contains(msg, "exceeds the maximum of 8")
}

test_weak_2sv_factor_denied if {
cfg := json.patch(compliant, [{"op": "replace", "path": "/predicate/config/policies/Root/security_two_step_verification_enforcement_factor/allowedSignInFactorSet", "value": "NO_TELEPHONY"}])
some msg in deny with input as cfg
contains(msg, "ONLY_SECURITY_KEY")
}

test_long_session_denied if {
cfg := json.patch(compliant, [{"op": "replace", "path": "/predicate/config/policies/Root/security_session_controls/webSessionDuration", "value": "72000s"}])
some msg in deny with input as cfg
contains(msg, "exceeds the 12h/43200s maximum")
}

test_unenforced_2sv_denied if {
cfg := json.patch(compliant, [{"op": "replace", "path": "/predicate/config/policies/Root/security_two_step_verification_enforcement/enforcedFrom", "value": ""}])
some msg in deny with input as cfg
contains(msg, "does not enforce 2-step verification")
}
38 changes: 38 additions & 0 deletions tool-scubagoggles-gws/policy/gws_drive.rego
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# gws_drive.rego
#
# Google Workspace Drive & Docs policy — controls the external flow of content
# (and therefore CUI). Evaluates the raw config captured by the `scubagoggles`
# attestor (input.predicate.config) and emits deny[] for policyverify.
#
# Coverage rationale: these are the GWS controls a NIST 800-171 assessment draws
# on for Access Control — limiting CUI flow to authorized parties (3.1.3) and
# controlling publicly accessible content (3.1.22). The GWS↔800-171 mapping is
# the platform's job; this policy checks GWS controls and references their ids.
#
# Our rego, not CISA's; control intent informed by CISA's SCuBA Drive baseline
# (https://github.com/cisagov/ScubaGoggles, CC0-1.0).

package gws_drive

import future.keywords.contains
import future.keywords.if
import future.keywords.in

config := input.predicate.config

# GWS.DRIVEDOCS.1.1 — external sharing SHALL NOT be unrestricted. Acceptable
# modes restrict the audience (DISALLOWED, ALLOWLISTED_DOMAINS); "ALLOWED" lets
# users share with anyone, an uncontrolled path for CUI to leave the boundary.
deny contains msg if {
some name, settings in config.policies
settings.drive_and_docs_external_sharing.externalSharingMode == "ALLOWED"
msg := sprintf("%q allows unrestricted external Drive sharing (externalSharingMode=ALLOWED) (GWS.DRIVEDOCS.1.1)", [name])
}

# GWS.DRIVEDOCS.4.1 — publishing files to the web SHALL be disabled (publicly
# accessible content must be controlled).
deny contains msg if {
some name, settings in config.policies
settings.drive_and_docs_external_sharing.allowPublishingFiles == true
msg := sprintf("%q allows publishing Drive files to the web (GWS.DRIVEDOCS.4.1)", [name])
}
31 changes: 31 additions & 0 deletions tool-scubagoggles-gws/policy/gws_drive_test.rego
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Unit tests for gws_drive.rego. Run with: opa test policy/
package gws_drive

import future.keywords.if
import future.keywords.in

compliant := {"predicate": {"config": {"policies": {"Root": {"drive_and_docs_external_sharing": {
"externalSharingMode": "DISALLOWED",
"allowPublishingFiles": false,
}}}}}}

test_compliant_has_no_denials if {
count(deny) == 0 with input as compliant
}

test_unrestricted_sharing_denied if {
cfg := json.patch(compliant, [{"op": "replace", "path": "/predicate/config/policies/Root/drive_and_docs_external_sharing/externalSharingMode", "value": "ALLOWED"}])
some m in deny with input as cfg
contains(m, "unrestricted external")
}

test_web_publishing_denied if {
cfg := json.patch(compliant, [{"op": "replace", "path": "/predicate/config/policies/Root/drive_and_docs_external_sharing/allowPublishingFiles", "value": true}])
some m in deny with input as cfg
contains(m, "publishing")
}

test_allowlisted_domains_ok if {
cfg := json.patch(compliant, [{"op": "replace", "path": "/predicate/config/policies/Root/drive_and_docs_external_sharing/externalSharingMode", "value": "ALLOWLISTED_DOMAINS"}])
count(deny) == 0 with input as cfg
}
65 changes: 65 additions & 0 deletions tool-scubagoggles-gws/policy/gws_gmail.rego
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# gws_gmail.rego
#
# Google Workspace Gmail policy — email-transmission integrity and anti-spoofing
# checks (DMARC / SPF / DKIM) plus attachment protection. Evaluates the raw
# config captured by the `scubagoggles` attestor (input.predicate.config) and
# emits deny[] for policyverify.
#
# Coverage rationale: these are the GWS controls a NIST 800-171 assessment draws
# on for the System & Communications Protection (3.13) and System & Information
# Integrity families — protecting the integrity/authenticity of CUI in transit.
# The GWS↔800-171 mapping itself is the platform's job; this policy only checks
# GWS controls and references their GWS ids.
#
# Our rego, not CISA's; control intent informed by CISA's SCuBA Gmail baseline
# (https://github.com/cisagov/ScubaGoggles, CC0-1.0).

package gws_gmail

import future.keywords.contains
import future.keywords.if
import future.keywords.in

config := input.predicate.config

# Normalize one DNS rdata string for substring checks.
norm(r) := replace(lower(r), " ", "")

# GWS.GMAIL.7 — DMARC SHALL enforce: policy must be reject or quarantine, not
# none. A published-but-p=none record monitors only and does not stop spoofing.
dmarc_enforced(rec) if {
some r in rec.rdata
contains(norm(r), "p=reject")
}

dmarc_enforced(rec) if {
some r in rec.rdata
contains(norm(r), "p=quarantine")
}

deny contains msg if {
some rec in config.dmarc_records
not dmarc_enforced(rec)
msg := sprintf("DMARC for %q does not enforce (p must be reject or quarantine) (GWS.GMAIL.7)", [rec.domain])
}

# GWS.GMAIL.3.1 — DKIM SHALL be enabled (a non-empty DKIM record published).
deny contains msg if {
some rec in config.dkim_records
count([r | some r in rec.rdata; r != ""]) == 0
msg := sprintf("no DKIM record published for %q (GWS.GMAIL.3.1)", [rec.domain])
}

# GWS.GMAIL.4.1 — an SPF policy SHALL be published.
deny contains msg if {
some rec in config.spf_records
count([r | some r in rec.rdata; startswith(norm(r), "v=spf1")]) == 0
msg := sprintf("no SPF record published for %q (GWS.GMAIL.4.1)", [rec.domain])
}

# GWS.GMAIL.5 — anomalous-attachment protection SHALL be enabled.
deny contains msg if {
some name, settings in config.policies
settings.gmail_email_attachment_safety.enableAnomalousAttachmentProtection == false
msg := sprintf("%q has anomalous-attachment protection disabled (GWS.GMAIL.5)", [name])
}
Loading