Compare commits
6 commits
5c33c8a821
...
570604ca80
| Author | SHA1 | Date | |
|---|---|---|---|
| 570604ca80 | |||
| d3a9ea7ad9 | |||
| 2607ca9b8a | |||
| b20e98b6aa | |||
| 460d5fdada | |||
| ca0364c197 |
14 changed files with 1081 additions and 67 deletions
171
SECURITY.md
Normal file
171
SECURITY.md
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
# Security Policy
|
||||
|
||||
This document describes how to report vulnerabilities in ZDDC and what to expect
|
||||
in response. Compatible with the OpenSSF [`SECURITY.md` template] and addresses
|
||||
NIST 800-53 SI-5 (vulnerability disclosure) for federal evaluators.
|
||||
|
||||
[`SECURITY.md` template]: https://github.com/ossf/oss-vulnerability-guide
|
||||
|
||||
## Supported versions
|
||||
|
||||
Security fixes are issued for:
|
||||
|
||||
- **Latest stable release** of every artifact (six HTML tools + zddc-server) —
|
||||
always supported.
|
||||
- **Previous stable major-minor** (e.g. `v1.2.x` while `v1.3.x` is current) —
|
||||
best-effort backports for issues with severity ≥ High (CVSS v3.1 ≥ 7.0).
|
||||
- **Earlier versions** — out of scope. Reproduce the issue on a current
|
||||
release before reporting.
|
||||
|
||||
zddc-server's release tags (`zddc-server-vX.Y.Z`) and the HTML tool tags
|
||||
(`<tool>-vX.Y.Z`) all share the same X.Y.Z on lockstep cuts; "current stable"
|
||||
means the highest X.Y.Z tagged across the seven artifacts.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Preferred channel: private email.**
|
||||
|
||||
Send a description of the issue, reproduction steps, and (if known) a
|
||||
suggested fix to:
|
||||
|
||||
> **caseywitt@proton.me**
|
||||
|
||||
Include in the email:
|
||||
|
||||
1. The artifact and version affected (e.g. `zddc-server-v0.0.16` or
|
||||
`archive-v0.0.16`).
|
||||
2. The shortest reproduction you can produce — a single curl command, a
|
||||
`.zddc` fixture that triggers the issue, etc.
|
||||
3. Your assessment of severity (CVSS v3.1 vector if you can; otherwise a
|
||||
short description of impact).
|
||||
4. Whether you'd like attribution in the eventual advisory or prefer to
|
||||
stay anonymous.
|
||||
|
||||
PGP encryption is available on request — reply to the initial email asking
|
||||
for the key; we'll send it from the same address.
|
||||
|
||||
**Secondary channel: GitHub Security Advisories.** This repository's
|
||||
upstream is at <https://codeberg.org/VARASYS/ZDDC>; a mirror is published
|
||||
on GitHub when one is needed for ecosystem compatibility. If you prefer
|
||||
the GitHub workflow over email, use the "Report a vulnerability" link on
|
||||
the GitHub mirror's Security tab. Codeberg does not yet have an
|
||||
equivalent of GitHub Security Advisories, so for embargoed coordination
|
||||
GitHub is the better path.
|
||||
|
||||
**Do not** open a public issue on either Codeberg or the GitHub mirror
|
||||
for security reports. Do not post reproduction details to any public
|
||||
chat, mailing list, or social channel until the embargo lifts.
|
||||
|
||||
## Response timeline
|
||||
|
||||
| Time | What to expect |
|
||||
|---|---|
|
||||
| Within 3 business days of report | Acknowledgement that we received it and are looking at it |
|
||||
| Within 14 days | Initial assessment: confirmed / not-a-vulnerability / need-more-info, with severity rating |
|
||||
| Within 90 days | Fix released, or an updated timeline if the issue is genuinely complex |
|
||||
|
||||
If you don't hear back within 3 business days, retry — the email may have
|
||||
been mis-filed. Mention "ZDDC security" in the subject so it stands out.
|
||||
|
||||
## Embargo and disclosure
|
||||
|
||||
We coordinate disclosure via embargo when the issue is non-trivial:
|
||||
|
||||
1. **Embargo agreed.** Reporter and maintainer agree on a public-disclosure
|
||||
date (default 90 days from confirmation; sooner for severity Critical
|
||||
issues with active exploitation).
|
||||
2. **Fix developed.** Patch + tests in a private branch; reproduction case
|
||||
added to the regression suite.
|
||||
3. **Pre-disclosure notification.** ~1 week before the embargo lifts, we
|
||||
notify known downstream redistributors (Helm-chart consumers,
|
||||
container-image bakers) so they can stage updates.
|
||||
4. **Coordinated release.** On the embargo date: release the fixed version,
|
||||
publish the advisory (with reporter attribution if desired), request a
|
||||
CVE if applicable.
|
||||
5. **Public reproduction details.** Public proof-of-concept code is held
|
||||
until 30 days after the fix release, to give operators a window to
|
||||
upgrade.
|
||||
|
||||
## CVE assignment
|
||||
|
||||
For issues meeting the [MITRE CVE rules][cve-rules] (severity ≥ Medium,
|
||||
publicly redistributed code, identifiable distinct vulnerability), we
|
||||
request CVE IDs via:
|
||||
|
||||
- **GitHub Security Advisories**, when the GitHub mirror is the
|
||||
disclosure channel (GitHub is a CVE Numbering Authority and assigns
|
||||
IDs automatically for advisories published through their flow).
|
||||
- **MITRE direct request**, otherwise, with the original report
|
||||
attached as evidence.
|
||||
|
||||
[cve-rules]: https://www.cve.org/Resources/General/Counting-Rules.pdf
|
||||
|
||||
The CVE ID, when assigned, appears in:
|
||||
|
||||
- The release-notes section of the fixed version's tag annotation.
|
||||
- The published advisory.
|
||||
- The relevant entry in `CHANGELOG.md` (when one exists; not every
|
||||
release ships with one).
|
||||
|
||||
## In-scope vulnerabilities
|
||||
|
||||
Examples of issues we want to hear about:
|
||||
|
||||
- Authentication / authorization bypass — anything that lets a caller
|
||||
reach a directory their `.zddc` chain or external OPA policy says
|
||||
they shouldn't.
|
||||
- Path traversal — directory listings, archive resolutions, or form
|
||||
submissions that escape `ZDDC_ROOT`.
|
||||
- Injection — XSS in tool HTML, command injection in shell scripts,
|
||||
YAML deserialization issues in `.zddc` parsing.
|
||||
- Cryptographic weaknesses — though the production binary uses Go
|
||||
stdlib crypto and the transmittal-signing feature uses Web Crypto;
|
||||
TLS hardening is configurable.
|
||||
- Cross-tool data leaks — one tool's storage being accessible via
|
||||
another tool's UI or URL.
|
||||
- Rate-limiting / resource-exhaustion — if a single unauthenticated
|
||||
request can pin CPU, exhaust file descriptors, or fill disk.
|
||||
- Supply-chain — tampering with vendored libraries, the `apps:` URL
|
||||
fetch path, or the cross-compile build.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Email-header forgery via direct connection to the bind address.**
|
||||
Documented behavior — `zddc-server` trusts whatever the upstream
|
||||
proxy sets as `X-Auth-Request-Email`. Network isolation is the
|
||||
operator's responsibility (see `zddc/README.md` § "Trust boundary").
|
||||
Reports of "I bypassed auth by curl-ing 127.0.0.1:8080 directly"
|
||||
are not vulnerabilities; the documented deployment model requires
|
||||
the bind address to be unreachable except via the proxy.
|
||||
- **Anonymous information disclosure on `/` and `/.profile`.**
|
||||
Documented as intentional — the public landing page is a project
|
||||
picker filtered by ACL. Operators who can't tolerate disclosure
|
||||
gate `/` behind their proxy's auth-required path.
|
||||
- **`apps:` URL-fetch trust.** Documented — fetched-once-and-cached,
|
||||
no integrity verification. Treat `.zddc` write authority as full
|
||||
UI-mounting authority. (SHA-256 pinning is on the federal-readiness
|
||||
roadmap; see `zddc/README.md` § "Federal-readiness gap analysis".)
|
||||
- **Theoretical issues without a working reproduction** — we'll engage
|
||||
with proof-of-concept code; not with hypothetical chains.
|
||||
- **Issues in third-party dependencies that we vendor** — report those
|
||||
to the upstream project. We'll backport their fix in the next
|
||||
release cut. (Vendored libs: jszip, docx-preview, xlsx in some
|
||||
HTML tools; gopkg.in/yaml.v3, fsnotify, lumberjack, klauspost
|
||||
compress in zddc-server.)
|
||||
- **Social engineering / phishing / misconfigured customer
|
||||
deployments** unless the misconfiguration is plausibly the default.
|
||||
|
||||
## Federal evaluators
|
||||
|
||||
Key references for ATO conversations:
|
||||
|
||||
- `zddc/README.md` § "Federal-readiness gap analysis" — every NIST
|
||||
control we know is incomplete, with the planned remediation.
|
||||
- `ARCHITECTURE.md` § "Server security model" — commercial-vs-federal
|
||||
trust model side-by-side.
|
||||
- `zddc/internal/policy/` — pluggable OPA-compatible policy decider
|
||||
for federal customers running their own audited Rego policies.
|
||||
|
||||
We do not currently hold an ATO, FedRAMP authorization, or any other
|
||||
formal accreditation. The system is designed to be ATO-able by
|
||||
customers; the documentation aims to make that path obvious.
|
||||
|
|
@ -13,7 +13,7 @@ This tool follows the "record player with the record" philosophy - the applicati
|
|||
## Quick Start
|
||||
|
||||
1. Open the editor in your browser
|
||||
2. Click **Select Directory** to choose a folder with markdown files
|
||||
2. Click **Add Local Directory** to choose a folder with markdown files
|
||||
3. Navigate the file tree on the left
|
||||
4. Click any `.md` file to edit it
|
||||
5. Click **Save File** or **Save All** to save changes
|
||||
|
|
|
|||
134
zddc/README.md
134
zddc/README.md
|
|
@ -344,7 +344,7 @@ naive intuition suggests.
|
|||
and serve arbitrary HTML to every viewer below that level. Subtree write
|
||||
authority on `.zddc` should be treated as full UI-mounting authority. The
|
||||
`_app/` cache is fetch-once-and-keep — operators clear it by deleting
|
||||
`<ZDDC_ROOT>/_app/<host>/<path>`. (See "Apps: virtual tool HTMLs" below for
|
||||
`<ZDDC_ROOT>/_app/<scheme>/<host>[:<port>]/<path>`. (See "Apps: virtual tool HTMLs" below for
|
||||
the resolver order; SHA-256 pinning is on the federal-readiness list, not
|
||||
currently implemented.)
|
||||
|
||||
|
|
@ -410,31 +410,45 @@ guarantee these for the model above to hold:
|
|||
SIEM) via a sidecar; do not treat the local rotation as the system of
|
||||
record.
|
||||
4. **`apps:` URL fetches have no integrity check.** Fetched once on first
|
||||
miss, cached at `<ZDDC_ROOT>/_app/<host>/<path>` forever — no SHA-256 pin,
|
||||
miss, cached at `<ZDDC_ROOT>/_app/<scheme>/<host>[:<port>]/<path>` forever — no SHA-256 pin,
|
||||
no signature. Use only URLs you control, treat the apps cache as a trust
|
||||
boundary, and audit who has `.zddc` write authority where.
|
||||
|
||||
### Debugging permissions
|
||||
|
||||
When a user reports "I can't see /Project-X/" and you need to figure out why,
|
||||
manual cascade-tracing is the current path:
|
||||
the fastest path is the built-in cascade tracer:
|
||||
|
||||
1. **Confirm the resolved email** — hit `/.admin/whoami` as the user (you'll
|
||||
need to have proxy auth working, or run the request *through* the proxy
|
||||
that signs them in). The page shows every header on the request and the
|
||||
`email` field zddc-server resolved.
|
||||
2. **List the chain.** From `<ZDDC_ROOT>` down to the requested directory,
|
||||
inspect each `.zddc` (most directories have none). For
|
||||
```
|
||||
GET /.profile/effective-policy?path=/Project-X/sub/&email=alice@mycompany.com
|
||||
```
|
||||
|
||||
(Admin-only — 404 to non-admins. Same gate as `/.profile/whoami`,
|
||||
`/.profile/config`, `/.profile/logs`.)
|
||||
|
||||
Returns JSON with the resolved policy chain (every level along the
|
||||
walk from `ZDDC_ROOT` to the requested directory), the decision the
|
||||
active decider produces, the per-level email-match breakdown
|
||||
(`decision_at_level: "allow" | "deny" | "no_match"`), and which
|
||||
decider is wired in (`*policy.InternalDecider`,
|
||||
`*policy.cachingDecider`, etc.). With `ZDDC_OPA_URL` pointing at an
|
||||
external OPA, the decision goes through that OPA — so this endpoint
|
||||
also doubles as a smoke test for the OPA wiring.
|
||||
|
||||
Manual procedure (if the endpoint isn't reachable for some reason):
|
||||
|
||||
1. **Confirm the resolved email** — hit `/.profile/whoami` as the
|
||||
user. Shows every header on the request and the `email` field
|
||||
zddc-server resolved.
|
||||
2. **List the chain.** From `<ZDDC_ROOT>` down to the requested
|
||||
directory, inspect each `.zddc` (most directories have none). For
|
||||
`/Project-X/sub/sub/`, that's `/.zddc`, `/Project-X/.zddc`,
|
||||
`/Project-X/sub/.zddc`, `/Project-X/sub/sub/.zddc` — read whatever exists.
|
||||
3. **Walk bottom-up.** At each level, mentally run `AllowedAtLevel`: deny
|
||||
patterns first (any match → blocked), then allow (any match → allowed).
|
||||
First explicit match in the bottom-up walk is the answer. Default-deny
|
||||
if `HasAnyFile=true` and nothing matches.
|
||||
|
||||
A built-in `/.admin/effective-policy?path=...&email=...` endpoint that does
|
||||
this trace and returns the chain + decision is on the future-work list (see
|
||||
below); until it ships, the manual procedure is the only path.
|
||||
`/Project-X/sub/.zddc`, `/Project-X/sub/sub/.zddc` — read whatever
|
||||
exists.
|
||||
3. **Walk bottom-up.** At each level, mentally run `AllowedAtLevel`:
|
||||
deny patterns first (any match → blocked), then allow (any match
|
||||
→ allowed). First explicit match in the bottom-up walk is the
|
||||
answer. Default-deny if `HasAnyFile=true` and nothing matches.
|
||||
|
||||
### Directory visibility
|
||||
|
||||
|
|
@ -552,10 +566,15 @@ have to redo the gap analysis from scratch.
|
|||
- **FIPS 140-3 cryptography** (NIST SC-13) — current build uses Go stdlib
|
||||
crypto. Required: build with `GOEXPERIMENT=systemcrypto` + RHEL FIPS
|
||||
userspace, or use the `microsoft/go` (formerly goboring) toolchain.
|
||||
- **TLS hardening** (NIST SC-8(1)) — server uses Go stdlib `tls.Config`
|
||||
defaults; no explicit `MinVersion`, `CipherSuites`, or curve list.
|
||||
Required: explicit `MinVersion: tls.VersionTLS12` (TLS 1.3 preferred),
|
||||
DoD-approved cipher allowlist, OCSP stapling, HSTS header.
|
||||
- **TLS hardening** (NIST SC-8(1)) — *partially complete.* Server now
|
||||
sets `MinVersion: tls.VersionTLS12`, the NIST SP 800-52 Rev. 2
|
||||
AEAD-only cipher allowlist (ECDHE+AES-GCM and ECDHE+ChaCha20Poly1305
|
||||
variants), curve preferences (X25519, P-256, P-384), and emits HSTS
|
||||
(`max-age=31536000; includeSubDomains`) when zddc-server itself
|
||||
terminates TLS. *Still required for full DoD STIG conformance:*
|
||||
OCSP stapling, certificate-transparency-log inclusion, and an
|
||||
audit-grade documentation pack mapping the cipher list to FIPS
|
||||
140-3 validated implementations.
|
||||
- **Authenticated proxy↔server channel** (NIST IA-3) — current trust is
|
||||
network-level isolation only. Required: mTLS or signed forwarding token
|
||||
(e.g. JWT signed by the proxy with a key zddc-server validates).
|
||||
|
|
@ -566,10 +585,16 @@ have to redo the gap analysis from scratch.
|
|||
allow/deny + a single root-admin role. Required: roles as first-class
|
||||
entities, `.zddc` syntax for role grants, identity-source-driven role
|
||||
assignment.
|
||||
- **Least-privilege bounding** (NIST AC-6) — leaf-allow-overrides-parent-deny
|
||||
is incompatible with default federal expectations. Required: a configurable
|
||||
enforcement mode where parent denies are absolute and only root admins can
|
||||
override.
|
||||
- **Least-privilege bounding** (NIST AC-6) — *partially complete.*
|
||||
Leaf-allow-overrides-parent-deny is the cascade's intentional
|
||||
delegation behavior in commercial mode and is preserved in the
|
||||
internal Go evaluator. For federal deployments, `--print-rego=federal`
|
||||
emits a parity-tested Rego policy where parent denies are absolute;
|
||||
drop it into an external OPA and point `ZDDC_OPA_URL` at it. *Still
|
||||
required for full coverage:* a built-in toggle (e.g. `ZDDC_POLICY_MODE=federal`)
|
||||
that switches the in-process Go evaluator's semantics without
|
||||
requiring an OPA sidecar — currently federal-mode is reachable only
|
||||
via the external-OPA path.
|
||||
- **Account lifecycle** (NIST AC-2) — emails as identifiers must tie to
|
||||
authoritative sources (PIV cert subject, IdP-managed identity). Required:
|
||||
documented integration with at least one IdP supporting federal identity
|
||||
|
|
@ -695,20 +720,36 @@ cache lookup would be.
|
|||
|
||||
### Reference Rego policy
|
||||
|
||||
The `--print-rego` flag emits the bundled reference Rego policy that
|
||||
mirrors internal-mode semantics exactly. Federal customers standing up
|
||||
their own OPA instance can use it as a starting point:
|
||||
The `--print-rego` flag emits the bundled reference Rego policies. Two
|
||||
variants ship:
|
||||
|
||||
```sh
|
||||
zddc-server --print-rego > /etc/opa/policies/zddc-access.rego
|
||||
zddc-server --print-rego # standard cascade (commercial)
|
||||
zddc-server --print-rego=standard # same
|
||||
zddc-server --print-rego=federal # parent-deny-is-absolute (NIST AC-6)
|
||||
```
|
||||
|
||||
The standard variant mirrors internal-mode semantics exactly — leaf-
|
||||
level allows can override an ancestor's deny (the cascade's intentional
|
||||
delegation property). The federal variant is the strict-least-privilege
|
||||
posture: any deny anywhere in the chain is absolute, no leaf-level
|
||||
override possible. Federal customers running their own OPA can drop
|
||||
the federal Rego in unchanged, or use either as a starting point for
|
||||
further customization.
|
||||
|
||||
Parity is enforced at build time. `zddc/internal/policy/parity_test.go`
|
||||
imports the OPA Go module **as a test-only dependency**, evaluates the
|
||||
bundled Rego against the same fixture set the internal Go evaluator
|
||||
runs, and fails CI on any divergence. The test-only import means the
|
||||
production binary stays OPA-free (still 13 MB) — the OPA library is in
|
||||
`go.mod` but not in `go build`'s output.
|
||||
imports the OPA Go module **as a test-only dependency**, evaluates both
|
||||
bundled Regos against fixture sets and asserts:
|
||||
|
||||
- The standard Rego matches the internal Go evaluator on every documented
|
||||
cascade scenario (`TestRegoParity_AllInternalCases`).
|
||||
- The federal Rego agrees with the standard policy on every case where
|
||||
no ancestor-deny intersects a leaf-allow, AND **disagrees** on the
|
||||
cases where the AC-6 rule differs (`TestFederalRego_DivergencesFromStandard`).
|
||||
This way both policies are guaranteed to behave as documented.
|
||||
|
||||
The test-only import means the production binary stays OPA-free (still
|
||||
13 MB) — the OPA library is in `go.mod` but not in `go build`'s output.
|
||||
|
||||
This gives you both ends of the spectrum: a single OPA-aware codebase
|
||||
where the production decider is pure Go (no library bloat, no extra
|
||||
|
|
@ -753,9 +794,6 @@ the *interpretation* of those files differs per tenant.
|
|||
Items the conversation flagged as friction in operator setup or as documented
|
||||
gaps that warrant code, in addition to the federal-readiness items above:
|
||||
|
||||
- `/.admin/effective-policy?path=...&email=...` endpoint returning the
|
||||
resolved chain + decision, so debugging permissions stops requiring manual
|
||||
cascade tracing.
|
||||
- `.zddc.form.yaml` ACL editor (built on the form-data system) once
|
||||
file-as-truth round-trip preserves comments — turn the manual YAML edit
|
||||
into a self-service UI for project owners.
|
||||
|
|
@ -773,22 +811,30 @@ gaps that warrant code, in addition to the federal-readiness items above:
|
|||
|
||||
## Admin Debug Page
|
||||
|
||||
`zddc-server` exposes a built-in debug page at `/.admin/` for operators who can
|
||||
`zddc-server` exposes a built-in debug page at `/.profile/` for operators who can
|
||||
push code/images but cannot `kubectl exec` into the running container. It surfaces:
|
||||
|
||||
- **`/.admin/whoami`** — every header on the current request, the configured email
|
||||
- **`/.profile/whoami`** — every header on the current request, the configured email
|
||||
header name, the value observed at that name, and the resolved email. This is the
|
||||
first thing to look at when access logs show `email=anonymous` — it tells you
|
||||
exactly which (if any) header the upstream proxy is sending.
|
||||
- **`/.admin/config`** — the resolved `Config` (env vars). Equivalent to
|
||||
- **`/.profile/config`** — the resolved `Config` (env vars). Equivalent to
|
||||
`kubectl exec -- env | grep ^ZDDC_` for diagnosing chart / deployment overrides.
|
||||
- **`/.admin/logs`** — recent log entries (last 500) from an in-memory ring buffer.
|
||||
- **`/.profile/logs`** — recent log entries (last 500) from an in-memory ring buffer.
|
||||
Optional `?level=info|warn|error|debug` and `?since=<RFC3339>` query params.
|
||||
At `ZDDC_LOG_LEVEL=debug` every request also logs its full header map under
|
||||
`msg=request headers` — useful for diagnosing proxy / SSO header passthrough
|
||||
(e.g. confirming which header carries the email). Note: that dump includes
|
||||
auth tokens and cookies; only enable debug in trusted environments.
|
||||
- **`/.admin/`** — HTML dashboard that fetches the three JSON endpoints client-side.
|
||||
- **`/.profile/effective-policy?path=...&email=...`** — cascade tracer.
|
||||
Returns the resolved policy chain (every level along the walk from
|
||||
`ZDDC_ROOT` to the requested path), the active decider's allow/deny
|
||||
verdict, the per-level email-match breakdown, and the decider kind
|
||||
(`*policy.InternalDecider` / `*policy.cachingDecider`). When
|
||||
`ZDDC_OPA_URL` points at an external OPA, the decision goes through
|
||||
that OPA — also a useful smoke test for OPA wiring. See "Debugging
|
||||
permissions" above.
|
||||
- **`/.profile/`** — HTML dashboard that fetches the JSON endpoints client-side.
|
||||
|
||||
### Authorization
|
||||
|
||||
|
|
@ -888,7 +934,7 @@ For any path, the resolution order is:
|
|||
3. **Embedded** — the build-time HTML compiled into the binary.
|
||||
|
||||
URL sources are fetched once on first request and cached forever in
|
||||
`<ZDDC_ROOT>/_app/<host>/<path>`. There is no background refresh and no
|
||||
`<ZDDC_ROOT>/_app/<scheme>/<host>[:<port>]/<path>`. There is no background refresh and no
|
||||
hash verification — to pull a new build, delete the cache file. Concurrent
|
||||
misses for the same URL share one outbound fetch (singleflight). Direct
|
||||
URL access to `/_app/...` is blocked at dispatch; cached HTMLs are served
|
||||
|
|
|
|||
|
|
@ -32,13 +32,21 @@ import (
|
|||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
// --print-rego: dump the bundled reference Rego policy and exit.
|
||||
// --print-rego: dump a bundled reference Rego policy and exit.
|
||||
// Cheap escape hatch for operators standing up an external OPA who want
|
||||
// the parity-tested baseline as a starting point for customization.
|
||||
// a parity-tested baseline as a starting point for customization.
|
||||
//
|
||||
// --print-rego → standard cascade (commercial default)
|
||||
// --print-rego=standard → same
|
||||
// --print-rego=federal → parent-deny-is-absolute (NIST AC-6)
|
||||
for _, a := range os.Args[1:] {
|
||||
if a == "--print-rego" {
|
||||
switch a {
|
||||
case "--print-rego", "--print-rego=standard":
|
||||
fmt.Print(policy.ReferenceRego)
|
||||
return
|
||||
case "--print-rego=federal":
|
||||
fmt.Print(policy.FederalRego)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -146,9 +154,20 @@ func main() {
|
|||
"url", cfg.OPAURL,
|
||||
"cache_ttl", cfg.OPACacheTTL)
|
||||
|
||||
mux.Handle("/", handler.ACLMiddleware(cfg, decider, handler.AccessLogMiddleware(auditLogger, handler.CORSMiddleware(cfg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Innermost handler: dispatch.
|
||||
var inner http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
dispatch(cfg, idx, logRing, appsServer, w, r)
|
||||
})))))
|
||||
})
|
||||
inner = handler.CORSMiddleware(cfg, inner)
|
||||
// HSTS only when zddc-server itself is the TLS-terminating endpoint.
|
||||
// Behind an upstream proxy terminating TLS (cfg.TLSMode=="none"), the
|
||||
// proxy is responsible for HSTS — adding it here would conflict.
|
||||
if useTLS {
|
||||
inner = handler.HSTSMiddleware(inner)
|
||||
}
|
||||
inner = handler.AccessLogMiddleware(auditLogger, inner)
|
||||
inner = handler.ACLMiddleware(cfg, decider, inner)
|
||||
mux.Handle("/", inner)
|
||||
|
||||
gzWrapper, err := newGzipWrapper()
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -37,7 +37,27 @@ func NewCache(root string) (*Cache, error) {
|
|||
func (c *Cache) Root() string { return c.root }
|
||||
|
||||
// keyForURL converts a URL into a relative filesystem path under the
|
||||
// cache root, e.g. "zddc.varasys.io/releases/archive_stable.html".
|
||||
// cache root.
|
||||
//
|
||||
// Layout: <scheme>/<host>[:<port>]/<path>. The full origin tuple is in
|
||||
// the key so two URLs that resolve different content cannot collide:
|
||||
//
|
||||
// https://example.com/x.html → https/example.com/x.html
|
||||
// http://example.com/x.html → http/example.com/x.html
|
||||
// https://example.com:8443/x.html → https/example.com:8443/x.html
|
||||
//
|
||||
// No port stripping. The previous behavior — collapsing :443 onto bare
|
||||
// host for https (and :80 for http) — was a defensible HTTP convention
|
||||
// but conflated "the operator wrote a URL with the default port" with
|
||||
// "the operator wrote a bare-host URL". With explicit port preserved,
|
||||
// every URL maps to exactly one filesystem path; operators can still
|
||||
// `ls _app/https/example.com/` to inspect what's cached. Scheme
|
||||
// segregation prevents an http:// hit from masquerading as an https://
|
||||
// hit when both are deliberately distinct (rare, but real on
|
||||
// reverse-proxied stacks where http and https serve different bytes).
|
||||
//
|
||||
// Host is lowercased so the canonical-host normalization survives
|
||||
// case-insensitive DNS. Port is preserved verbatim.
|
||||
func keyForURL(rawURL string) (string, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
|
|
@ -52,13 +72,15 @@ func keyForURL(rawURL string) (string, error) {
|
|||
if u.RawQuery != "" {
|
||||
return "", fmt.Errorf("URL must not contain query string: %s", rawURL)
|
||||
}
|
||||
host := strings.ToLower(u.Host)
|
||||
// Lowercase the host part but preserve the port verbatim. Without
|
||||
// this we'd lowercase a numeric port unnecessarily, which is fine
|
||||
// but pointless; with this the ASCII-cased host normalization
|
||||
// works the same for both default and explicit-port URLs.
|
||||
host := u.Host
|
||||
if i := strings.Index(host, ":"); i >= 0 {
|
||||
port := host[i+1:]
|
||||
hostOnly := host[:i]
|
||||
if (u.Scheme == "http" && port == "80") || (u.Scheme == "https" && port == "443") {
|
||||
host = hostOnly
|
||||
}
|
||||
host = strings.ToLower(host[:i]) + host[i:]
|
||||
} else {
|
||||
host = strings.ToLower(host)
|
||||
}
|
||||
p := u.Path
|
||||
for strings.Contains(p, "//") {
|
||||
|
|
@ -72,7 +94,7 @@ func keyForURL(rawURL string) (string, error) {
|
|||
if strings.Contains(cleaned, "..") {
|
||||
return "", fmt.Errorf("URL path contains '..'")
|
||||
}
|
||||
return host + cleaned, nil
|
||||
return u.Scheme + "/" + host + cleaned, nil
|
||||
}
|
||||
|
||||
func (c *Cache) pathFor(rawURL string) (string, error) {
|
||||
|
|
|
|||
|
|
@ -11,14 +11,26 @@ func TestKeyForURL(t *testing.T) {
|
|||
cases := []struct {
|
||||
raw, want string
|
||||
}{
|
||||
{"https://zddc.varasys.io/releases/archive_stable.html", "zddc.varasys.io/releases/archive_stable.html"},
|
||||
{"https://ZDDC.Varasys.IO/releases/archive_stable.html", "zddc.varasys.io/releases/archive_stable.html"},
|
||||
{"http://example.com:80/foo.html", "example.com/foo.html"},
|
||||
{"https://example.com:443/foo.html", "example.com/foo.html"},
|
||||
{"https://example.com:8443/foo.html", "example.com:8443/foo.html"},
|
||||
{"https://example.com/", "example.com/index.html"},
|
||||
{"https://example.com", "example.com/index.html"},
|
||||
{"https://example.com//foo//bar.html", "example.com/foo/bar.html"},
|
||||
// Default ports are PRESERVED — no port-stripping (the previous
|
||||
// behavior conflated "operator wrote :443" with "operator wrote
|
||||
// bare host"; with the full origin in the key, every URL maps
|
||||
// to exactly one path).
|
||||
{"https://zddc.varasys.io/releases/archive_stable.html", "https/zddc.varasys.io/releases/archive_stable.html"},
|
||||
{"https://ZDDC.Varasys.IO/releases/archive_stable.html", "https/zddc.varasys.io/releases/archive_stable.html"},
|
||||
{"http://example.com/foo.html", "http/example.com/foo.html"},
|
||||
{"http://example.com:80/foo.html", "http/example.com:80/foo.html"},
|
||||
{"https://example.com/foo.html", "https/example.com/foo.html"},
|
||||
{"https://example.com:443/foo.html", "https/example.com:443/foo.html"},
|
||||
{"https://example.com:8443/foo.html", "https/example.com:8443/foo.html"},
|
||||
// Scheme segregation: same host+path under http and https map
|
||||
// to different cache entries (defensive against reverse-proxy
|
||||
// stacks that legitimately serve different bytes per scheme).
|
||||
{"http://example.com/x.html", "http/example.com/x.html"},
|
||||
{"https://example.com/x.html", "https/example.com/x.html"},
|
||||
// Path normalization preserved.
|
||||
{"https://example.com/", "https/example.com/index.html"},
|
||||
{"https://example.com", "https/example.com/index.html"},
|
||||
{"https://example.com//foo//bar.html", "https/example.com/foo/bar.html"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.raw, func(t *testing.T) {
|
||||
|
|
@ -33,6 +45,38 @@ func TestKeyForURL(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestKeyForURL_NoCollisions: explicit assertion that the dimensions
|
||||
// previously collapsed (default-port ↔ bare-host, http ↔ https) are
|
||||
// now distinct. Any future change that re-introduces collapsing will
|
||||
// fail this test.
|
||||
func TestKeyForURL_NoCollisions(t *testing.T) {
|
||||
pairs := [][2]string{
|
||||
// Different scheme, same host+path
|
||||
{"http://example.com/x.html", "https://example.com/x.html"},
|
||||
// https default port preserved (not collapsed onto bare host)
|
||||
{"https://example.com/x.html", "https://example.com:443/x.html"},
|
||||
// http default port preserved
|
||||
{"http://example.com/x.html", "http://example.com:80/x.html"},
|
||||
// Different non-default ports
|
||||
{"https://example.com:8443/x.html", "https://example.com:9443/x.html"},
|
||||
}
|
||||
for _, p := range pairs {
|
||||
t.Run(p[0]+" vs "+p[1], func(t *testing.T) {
|
||||
a, err := keyForURL(p[0])
|
||||
if err != nil {
|
||||
t.Fatalf("keyForURL(%q): %v", p[0], err)
|
||||
}
|
||||
b, err := keyForURL(p[1])
|
||||
if err != nil {
|
||||
t.Fatalf("keyForURL(%q): %v", p[1], err)
|
||||
}
|
||||
if a == b {
|
||||
t.Errorf("collision: %q and %q both → %q", p[0], p[1], a)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyForURL_Errors(t *testing.T) {
|
||||
cases := []string{
|
||||
"",
|
||||
|
|
|
|||
|
|
@ -91,6 +91,26 @@ func (rw *responseWriter) Write(b []byte) (int, error) {
|
|||
return n, err
|
||||
}
|
||||
|
||||
// HSTSMiddleware sets the Strict-Transport-Security response header,
|
||||
// instructing browsers to refuse plain-HTTP connections to this host
|
||||
// for the next year (NIST SP 800-52 Rev. 2 § 4.4.6, also DoD STIG
|
||||
// expectation; OWASP recommendation max-age >= 1 year). Use ONLY when
|
||||
// zddc-server is itself terminating TLS — when an upstream proxy
|
||||
// terminates, that proxy should set HSTS instead.
|
||||
//
|
||||
// includeSubDomains is set; preload is not (preload requires
|
||||
// pre-submitting the domain to the browser-vendor list — out of
|
||||
// scope for this server, and operators who want it can override
|
||||
// upstream).
|
||||
//
|
||||
// max-age = 31536000 = 365 days.
|
||||
func HSTSMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// AccessLogMiddleware logs a structured line per HTTP request after the
|
||||
// response is written.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package handler
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
|
@ -63,6 +64,12 @@ func ServeProfile(cfg config.Config, ring *LogRing, w http.ResponseWriter, r *ht
|
|||
return
|
||||
}
|
||||
serveProfileLogs(ring, w, r)
|
||||
case "/effective-policy":
|
||||
if !zddc.IsAdmin(cfg.Root, email) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
serveProfileEffectivePolicy(cfg, w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
|
@ -250,3 +257,180 @@ func levelRank(s string) int {
|
|||
return 1 // unknown → info
|
||||
}
|
||||
}
|
||||
|
||||
// serveProfileEffectivePolicy is the cascade-tracer endpoint:
|
||||
// /.profile/effective-policy?path=<URL-path>&email=<email>
|
||||
// returns the resolved policy chain plus the allow/deny decision the
|
||||
// active decider produces, in JSON. Eliminates the need for operators
|
||||
// to manual-trace .zddc files when debugging "why can't alice see
|
||||
// /Project-X?" reports.
|
||||
//
|
||||
// Both query params are required. The endpoint is admin-only (404 to
|
||||
// non-admins via the dispatch gate).
|
||||
//
|
||||
// Response shape (each chain level is a directory along the walk
|
||||
// from ZDDC_ROOT down to the requested path):
|
||||
//
|
||||
// {
|
||||
// "path": "/Project-X/sub/",
|
||||
// "email": "alice@mycompany.com",
|
||||
// "decision": true,
|
||||
// "decider_kind": "*policy.InternalDecider",
|
||||
// "chain": {
|
||||
// "has_any_file": true,
|
||||
// "levels": [
|
||||
// {"path": "/", "exists": true, "acl": {"allow": [...]}, "admins": [...]},
|
||||
// {"path": "/Project-X/", "exists": false},
|
||||
// {"path": "/Project-X/sub/", "exists": true, "acl": {"allow": [...]}}
|
||||
// ]
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Note: this evaluates the same input the production hot path would
|
||||
// build for a request from <email> to <path>; if zddc-server is
|
||||
// configured for external OPA, the decision goes through that OPA
|
||||
// (so this endpoint is also a useful smoke test for the OPA wiring).
|
||||
func serveProfileEffectivePolicy(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
probePath := q.Get("path")
|
||||
probeEmail := q.Get("email")
|
||||
if probePath == "" || probeEmail == "" {
|
||||
http.Error(w, "both ?path= and ?email= are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(probePath, "/") {
|
||||
http.Error(w, "path must start with /", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve the URL path to a filesystem directory the same way the
|
||||
// dispatch hot path does.
|
||||
rel := strings.TrimPrefix(probePath, "/")
|
||||
rel = strings.TrimSuffix(rel, "/")
|
||||
absDir, ok := safeJoin(cfg.Root, rel)
|
||||
if !ok {
|
||||
http.Error(w, "path escapes ZDDC_ROOT", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
chain, err := zddc.EffectivePolicy(cfg.Root, absDir)
|
||||
if err != nil {
|
||||
http.Error(w, "policy chain error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Evaluate the decision through whatever decider is wired into the
|
||||
// request — internal in commercial deployments, an external OPA in
|
||||
// federal ones. The returned bool is "allow".
|
||||
ctx := r.Context()
|
||||
decider := DeciderFromContext(r)
|
||||
allow, _ := policy.AllowFromChain(ctx, decider, chain, probeEmail, probePath)
|
||||
|
||||
type levelView struct {
|
||||
Index int `json:"index"`
|
||||
ZddcPath string `json:"zddc_path"`
|
||||
Exists bool `json:"exists"`
|
||||
Acl *zddc.ACLRules `json:"acl,omitempty"`
|
||||
Admins []string `json:"admins,omitempty"`
|
||||
AnyMatch bool `json:"matches_email"`
|
||||
Decision string `json:"decision_at_level"`
|
||||
}
|
||||
|
||||
// Build the per-level breakdown by walking the chain levels in
|
||||
// the same order the cascade does (root → leaf in the data, but
|
||||
// the live evaluator walks bottom-up). For each level we report
|
||||
// whether the file actually existed (HasAnyFile is global; we
|
||||
// don't have per-level existence, but ZddcFile.Admins/ACL being
|
||||
// non-empty is a reasonable proxy).
|
||||
out := struct {
|
||||
Path string `json:"path"`
|
||||
Email string `json:"email"`
|
||||
Decision bool `json:"decision"`
|
||||
DeciderKind string `json:"decider_kind"`
|
||||
Chain struct {
|
||||
HasAnyFile bool `json:"has_any_file"`
|
||||
Levels []levelView `json:"levels"`
|
||||
} `json:"chain"`
|
||||
}{
|
||||
Path: probePath,
|
||||
Email: probeEmail,
|
||||
Decision: allow,
|
||||
DeciderKind: deciderKind(decider),
|
||||
}
|
||||
out.Chain.HasAnyFile = chain.HasAnyFile
|
||||
|
||||
// Reconstruct level paths from cfg.Root. This mirrors how
|
||||
// zddc.EffectivePolicy builds the chain (see cascade.go).
|
||||
levelPaths := []string{cfg.Root}
|
||||
if rel != "" {
|
||||
current := cfg.Root
|
||||
for _, seg := range strings.Split(rel, "/") {
|
||||
if seg == "" {
|
||||
continue
|
||||
}
|
||||
current = current + "/" + seg
|
||||
levelPaths = append(levelPaths, current)
|
||||
}
|
||||
}
|
||||
|
||||
for i, lvl := range chain.Levels {
|
||||
var lp string
|
||||
if i < len(levelPaths) {
|
||||
// Map filesystem path back to a URL-style path under
|
||||
// cfg.Root for legibility in the response.
|
||||
fsPath := levelPaths[i]
|
||||
urlPath := strings.TrimPrefix(fsPath, cfg.Root)
|
||||
if urlPath == "" {
|
||||
urlPath = "/"
|
||||
}
|
||||
lp = urlPath + "/.zddc"
|
||||
}
|
||||
entry := levelView{
|
||||
Index: i,
|
||||
ZddcPath: lp,
|
||||
Exists: len(lvl.Admins) > 0 || len(lvl.ACL.Allow) > 0 || len(lvl.ACL.Deny) > 0,
|
||||
}
|
||||
if entry.Exists {
|
||||
entry.Acl = &lvl.ACL
|
||||
entry.Admins = lvl.Admins
|
||||
}
|
||||
// Per-level email match: would this level's deny or allow
|
||||
// patterns hit the email if checked? Reuses the same
|
||||
// MatchesPattern code the live evaluator does.
|
||||
anyMatch := false
|
||||
decisionAtLevel := "no_match"
|
||||
for _, p := range lvl.ACL.Deny {
|
||||
if zddc.MatchesPattern(p, probeEmail) {
|
||||
anyMatch = true
|
||||
decisionAtLevel = "deny"
|
||||
break
|
||||
}
|
||||
}
|
||||
if !anyMatch {
|
||||
for _, p := range lvl.ACL.Allow {
|
||||
if zddc.MatchesPattern(p, probeEmail) {
|
||||
anyMatch = true
|
||||
decisionAtLevel = "allow"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
entry.AnyMatch = anyMatch
|
||||
entry.Decision = decisionAtLevel
|
||||
out.Chain.Levels = append(out.Chain.Levels, entry)
|
||||
}
|
||||
|
||||
writeJSON(w, out)
|
||||
}
|
||||
|
||||
// deciderKind returns a short string label for the active decider.
|
||||
// Mirrors the helper used in policy package tests; duplicated here
|
||||
// to avoid a cross-package import that would only exist for one
|
||||
// debug-endpoint string.
|
||||
func deciderKind(d policy.Decider) string {
|
||||
if d == nil {
|
||||
return "nil"
|
||||
}
|
||||
t := fmt.Sprintf("%T", d)
|
||||
return t
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,12 @@ func TestServeProfileGateMatrix(t *testing.T) {
|
|||
{"admin /.profile/whoami", "/.profile/whoami", "alice@example.com", http.StatusOK},
|
||||
{"admin /.profile/config", "/.profile/config", "alice@example.com", http.StatusOK},
|
||||
{"admin /.profile/logs", "/.profile/logs", "alice@example.com", http.StatusOK},
|
||||
// effective-policy is admin-only too. With no params an admin
|
||||
// gets 400 (bad request), confirming the gate cleared. Same
|
||||
// 404 for non-admins as the other admin-only routes.
|
||||
{"anonymous /.profile/effective-policy", "/.profile/effective-policy", "", http.StatusNotFound},
|
||||
{"non-admin /.profile/effective-policy", "/.profile/effective-policy", "bob@example.com", http.StatusNotFound},
|
||||
{"admin /.profile/effective-policy without params", "/.profile/effective-policy", "alice@example.com", http.StatusBadRequest},
|
||||
|
||||
// Unknown sub-route still 404.
|
||||
{"admin unknown subroute", "/.profile/nope", "alice@example.com", http.StatusNotFound},
|
||||
|
|
@ -469,6 +475,86 @@ func TestServeProfileAccessJSON_SubtreeAdminScopes(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestServeProfileEffectivePolicy: admin queries the cascade tracer for a
|
||||
// (path, email) tuple and gets back the resolved chain plus the decision.
|
||||
// The fixture mirrors the worked-example layout from zddc/README.md (a
|
||||
// closed project where alice is allow-listed but bob is not, even though
|
||||
// /Archive/ would let *@mycompany.com in).
|
||||
func TestServeProfileEffectivePolicy(t *testing.T) {
|
||||
cfg, ring := profileTestRoot(t, []string{"super@admin.com"})
|
||||
if err := os.MkdirAll(filepath.Join(cfg.Root, "Closed-Project"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(cfg.Root, "Closed-Project", ".zddc"),
|
||||
[]byte("acl:\n allow:\n - alice@mycompany.com\n"), 0o644); err != nil {
|
||||
t.Fatalf("write child .zddc: %v", err)
|
||||
}
|
||||
zddc.InvalidateCache(cfg.Root)
|
||||
|
||||
// Trace alice (allowed at the leaf).
|
||||
rec := httptest.NewRecorder()
|
||||
r := requestWithEmail(http.MethodGet,
|
||||
"/.profile/effective-policy?path=/Closed-Project/&email=alice@mycompany.com",
|
||||
"super@admin.com")
|
||||
ServeProfile(cfg, ring, rec, r)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Path string `json:"path"`
|
||||
Email string `json:"email"`
|
||||
Decision bool `json:"decision"`
|
||||
Chain struct {
|
||||
HasAnyFile bool `json:"has_any_file"`
|
||||
Levels []struct {
|
||||
Index int `json:"index"`
|
||||
Exists bool `json:"exists"`
|
||||
MatchesEmail bool `json:"matches_email"`
|
||||
DecisionAtLevel string `json:"decision_at_level"`
|
||||
} `json:"levels"`
|
||||
} `json:"chain"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if resp.Path != "/Closed-Project/" || resp.Email != "alice@mycompany.com" {
|
||||
t.Errorf("path/email round-trip mismatch: %+v", resp)
|
||||
}
|
||||
if !resp.Decision {
|
||||
t.Errorf("decision = false, want true (alice is allow-listed at /Closed-Project/)")
|
||||
}
|
||||
if !resp.Chain.HasAnyFile {
|
||||
t.Error("HasAnyFile = false, want true (.zddc files exist)")
|
||||
}
|
||||
if len(resp.Chain.Levels) != 2 {
|
||||
t.Fatalf("levels count = %d, want 2 (root + Closed-Project/)", len(resp.Chain.Levels))
|
||||
}
|
||||
// Leaf level should have matched alice with allow.
|
||||
leaf := resp.Chain.Levels[1]
|
||||
if !leaf.MatchesEmail || leaf.DecisionAtLevel != "allow" {
|
||||
t.Errorf("leaf decision = %q (matches=%v), want allow (matches=true)", leaf.DecisionAtLevel, leaf.MatchesEmail)
|
||||
}
|
||||
|
||||
// Trace bob (not allow-listed; root has no broad allow either).
|
||||
rec2 := httptest.NewRecorder()
|
||||
r2 := requestWithEmail(http.MethodGet,
|
||||
"/.profile/effective-policy?path=/Closed-Project/&email=bob@mycompany.com",
|
||||
"super@admin.com")
|
||||
ServeProfile(cfg, ring, rec2, r2)
|
||||
if rec2.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec2.Code, rec2.Body.String())
|
||||
}
|
||||
var resp2 struct {
|
||||
Decision bool `json:"decision"`
|
||||
}
|
||||
if err := json.Unmarshal(rec2.Body.Bytes(), &resp2); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if resp2.Decision {
|
||||
t.Error("decision = true for bob, want false (no .zddc match anywhere; HasAnyFile=true → default-deny)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeProfileNoAdminsConfiguredStillRendersPage(t *testing.T) {
|
||||
// .zddc exists but has no admins list — page is still reachable,
|
||||
// but the admin/super-admin sections are absent.
|
||||
|
|
|
|||
179
zddc/internal/policy/federal_parity_test.go
Normal file
179
zddc/internal/policy/federal_parity_test.go
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
package policy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
|
||||
"github.com/open-policy-agent/opa/rego"
|
||||
)
|
||||
|
||||
// TestFederalRego_DivergencesFromStandard validates the federal-mode
|
||||
// variant by asserting both that:
|
||||
//
|
||||
// (a) most cascade scenarios produce the same verdict as standard
|
||||
// (the federal rule reduces to standard whenever no parent deny
|
||||
// intersects a leaf allow), AND
|
||||
//
|
||||
// (b) the specific scenarios where the rules differ (a leaf-level
|
||||
// allow overlaying an ancestor's deny) produce DIFFERENT verdicts:
|
||||
// standard says allow (leaf wins); federal says deny (ancestor
|
||||
// deny is absolute — NIST AC-6 default).
|
||||
//
|
||||
// Like the standard parity test, this imports the OPA library as a
|
||||
// test-only dependency. The federal Rego is a deployable artifact
|
||||
// (operators dump it via --print-rego=federal); the parity guard
|
||||
// here proves the artifact behaves as documented.
|
||||
func TestFederalRego_DivergencesFromStandard(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
standard, err := rego.New(
|
||||
rego.Query("data.zddc.access.allow"),
|
||||
rego.Module("access.rego", ReferenceRego),
|
||||
).PrepareForEval(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("compile standard rego: %v", err)
|
||||
}
|
||||
federal, err := rego.New(
|
||||
rego.Query("data.zddc.access_federal.allow"),
|
||||
rego.Module("access_federal.rego", FederalRego),
|
||||
).PrepareForEval(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("compile federal rego: %v", err)
|
||||
}
|
||||
|
||||
allow := func(p ...string) zddc.ZddcFile { return zddc.ZddcFile{ACL: zddc.ACLRules{Allow: p}} }
|
||||
deny := func(p ...string) zddc.ZddcFile { return zddc.ZddcFile{ACL: zddc.ACLRules{Deny: p}} }
|
||||
empty := zddc.ZddcFile{}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
chain zddc.PolicyChain
|
||||
email string
|
||||
wantStandard bool
|
||||
wantFederal bool
|
||||
divergesByDesign bool // true if standard and federal must disagree here
|
||||
}{
|
||||
// ── Cases where the two policies must AGREE ────────────────
|
||||
{
|
||||
"empty chain, no files",
|
||||
zddc.PolicyChain{HasAnyFile: false},
|
||||
"alice@example.com",
|
||||
true, true, false,
|
||||
},
|
||||
{
|
||||
"files exist, no rule matches → both deny",
|
||||
zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@trusted.com")}, HasAnyFile: true},
|
||||
"alice@example.com",
|
||||
false, false, false,
|
||||
},
|
||||
{
|
||||
"leaf allow with no ancestor deny → both allow",
|
||||
zddc.PolicyChain{Levels: []zddc.ZddcFile{empty, allow("*@example.com")}, HasAnyFile: true},
|
||||
"alice@example.com",
|
||||
true, true, false,
|
||||
},
|
||||
{
|
||||
"only deny anywhere → both deny",
|
||||
zddc.PolicyChain{Levels: []zddc.ZddcFile{deny("alice@example.com")}, HasAnyFile: true},
|
||||
"alice@example.com",
|
||||
false, false, false,
|
||||
},
|
||||
{
|
||||
"glob allow, no deny → both allow",
|
||||
zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@example.com")}, HasAnyFile: true},
|
||||
"alice@example.com",
|
||||
true, true, false,
|
||||
},
|
||||
|
||||
// ── The signature divergence: leaf allow overlaying ancestor deny ──
|
||||
{
|
||||
"leaf allows what parent denied → standard allows, federal denies (AC-6)",
|
||||
zddc.PolicyChain{Levels: []zddc.ZddcFile{
|
||||
deny("alice@example.com"),
|
||||
allow("alice@example.com"),
|
||||
}, HasAnyFile: true},
|
||||
"alice@example.com",
|
||||
true, // standard: leaf wins
|
||||
false, // federal: parent deny is absolute
|
||||
true,
|
||||
},
|
||||
{
|
||||
"deep leaf re-allows after middle deny → standard allows, federal denies",
|
||||
zddc.PolicyChain{Levels: []zddc.ZddcFile{
|
||||
allow("*@example.com"),
|
||||
deny("alice@example.com"),
|
||||
allow("alice@example.com"),
|
||||
}, HasAnyFile: true},
|
||||
"alice@example.com",
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"glob deny at root, specific allow at leaf → both differ",
|
||||
zddc.PolicyChain{Levels: []zddc.ZddcFile{
|
||||
deny("*@example.com"),
|
||||
allow("alice@example.com"),
|
||||
}, HasAnyFile: true},
|
||||
"alice@example.com",
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
input := AllowInput{Path: "/test", PolicyChain: chainToSerializable(tc.chain)}
|
||||
input.User.Email = tc.email
|
||||
regoInput, err := canonicalInput(input)
|
||||
if err != nil {
|
||||
t.Fatalf("encode input: %v", err)
|
||||
}
|
||||
|
||||
std, err := standard.Eval(ctx, rego.EvalInput(regoInput))
|
||||
if err != nil {
|
||||
t.Fatalf("standard eval: %v", err)
|
||||
}
|
||||
fed, err := federal.Eval(ctx, rego.EvalInput(regoInput))
|
||||
if err != nil {
|
||||
t.Fatalf("federal eval: %v", err)
|
||||
}
|
||||
if len(std) == 0 || len(fed) == 0 {
|
||||
t.Fatal("rego returned empty result set")
|
||||
}
|
||||
stdAllow := std[0].Expressions[0].Value.(bool)
|
||||
fedAllow := fed[0].Expressions[0].Value.(bool)
|
||||
|
||||
if stdAllow != tc.wantStandard {
|
||||
t.Errorf("standard rego: got %v, want %v", stdAllow, tc.wantStandard)
|
||||
}
|
||||
if fedAllow != tc.wantFederal {
|
||||
t.Errorf("federal rego: got %v, want %v", fedAllow, tc.wantFederal)
|
||||
}
|
||||
// Cross-check the divergence flag itself: if we said the cases
|
||||
// must disagree, they must; if we said they agree, they must.
|
||||
diverges := stdAllow != fedAllow
|
||||
if diverges != tc.divergesByDesign {
|
||||
t.Errorf("divergence = %v, want %v (standard=%v, federal=%v)",
|
||||
diverges, tc.divergesByDesign, stdAllow, fedAllow)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFederalRego_RegoCompiles is a sanity check that the embedded
|
||||
// federal Rego file parses without error in OPA, separate from the
|
||||
// behavior tests. Catches accidental syntax breakage in
|
||||
// access_federal.rego before running the (slower) parity matrix.
|
||||
func TestFederalRego_RegoCompiles(t *testing.T) {
|
||||
_, err := rego.New(
|
||||
rego.Query("data.zddc.access_federal.allow"),
|
||||
rego.Module("access_federal.rego", FederalRego),
|
||||
).PrepareForEval(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("federal rego does not compile: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,9 @@ import _ "embed"
|
|||
// Customizations typical for federal deployments:
|
||||
//
|
||||
// - Flip the leaf-allow-overrides-parent-deny semantics so parent denies
|
||||
// are absolute (NIST AC-6 least-privilege posture).
|
||||
// are absolute (NIST AC-6 least-privilege posture). For this specific
|
||||
// case zddc-server ships a parity-tested federal-mode variant; see
|
||||
// FederalRego and `--print-rego=federal`.
|
||||
// - Add role-based access via additional input fields (input.user.roles
|
||||
// populated by the upstream proxy from SAML/OIDC claims).
|
||||
// - Add time-of-day or IP-range constraints.
|
||||
|
|
@ -24,3 +26,21 @@ import _ "embed"
|
|||
//
|
||||
//go:embed rego/access.rego
|
||||
var ReferenceRego string
|
||||
|
||||
// FederalRego is the strict-least-privilege variant of ReferenceRego
|
||||
// where parent denies are absolute (NIST AC-6). Drop-in for federal
|
||||
// customers who need the AC-6 posture without writing Rego from
|
||||
// scratch:
|
||||
//
|
||||
// zddc-server --print-rego=federal > /etc/opa/policies/zddc-access.rego
|
||||
//
|
||||
// The internal Go evaluator does NOT implement these semantics — it
|
||||
// stays on the commercial cascade. Federal-mode is reachable only by
|
||||
// running OPA with this policy and pointing ZDDC_OPA_URL at it. See
|
||||
// zddc/internal/policy/rego/access_federal.rego for the policy itself
|
||||
// and federal_parity_test.go for the divergence-test fixtures (cases
|
||||
// where federal-mode and commercial-mode disagree, asserting each gives
|
||||
// the expected verdict).
|
||||
//
|
||||
//go:embed rego/access_federal.rego
|
||||
var FederalRego string
|
||||
|
|
|
|||
95
zddc/internal/policy/rego/access_federal.rego
Normal file
95
zddc/internal/policy/rego/access_federal.rego
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# Federal-mode reference policy: parent-deny-is-absolute (NIST AC-6).
|
||||
#
|
||||
# This is a strict-least-privilege variant of access.rego. The two policies
|
||||
# differ in exactly one rule, but the semantic difference is meaningful for
|
||||
# federal evaluators:
|
||||
#
|
||||
# access.rego (commercial, default):
|
||||
# "Bottom-up walk; first explicit match wins; deny-first within a level.
|
||||
# A leaf-level allow CAN override an ancestor's deny."
|
||||
# Test: cascade_test.go "leaf allows user that parent denies → leaf wins".
|
||||
#
|
||||
# access_federal.rego (federal):
|
||||
# "Any deny anywhere along the chain is absolute. An allow only matters
|
||||
# if no ancestor (or sibling level) has denied the same email. Leaf-
|
||||
# level allows do NOT override ancestor denies."
|
||||
# Required by NIST AC-6 (Least Privilege) default expectations: a
|
||||
# central admin's deny at the root must be unbypassable by a tenant
|
||||
# who controls a subtree's .zddc.
|
||||
#
|
||||
# Why ship two policies? The internal Go evaluator (in zddc/internal/zddc/
|
||||
# acl.go) implements only the commercial cascade — it's the rule the
|
||||
# default deployment exercises. Federal customers running their own OPA
|
||||
# with this file get the strict variant without any zddc-server code
|
||||
# change. They can also write a hybrid policy (e.g. "deny is absolute
|
||||
# only for emails matching some pattern; cascade rules for everyone
|
||||
# else") since once they're hosting their own OPA, the constraint is
|
||||
# whatever they write.
|
||||
#
|
||||
# Input shape: identical to access.rego — see that file's docstring.
|
||||
|
||||
package zddc.access_federal
|
||||
|
||||
import future.keywords.if
|
||||
import future.keywords.in
|
||||
|
||||
default allow := false
|
||||
|
||||
# Allow when no .zddc files exist anywhere AND no rule matches.
|
||||
# Same default-allow case as commercial; preserves the empty-tree
|
||||
# behaviour. (zddc-server's --insecure check at startup makes this
|
||||
# unreachable in any non-deliberately-public deployment.)
|
||||
allow if {
|
||||
not input.policy_chain.has_any_file
|
||||
not any_deny_match
|
||||
not any_allow_match
|
||||
}
|
||||
|
||||
# Allow when files exist, no level (any depth) denies, and at least
|
||||
# one level allows. The "any level" check is what makes parent denies
|
||||
# absolute — there is no "deepest match wins" rule here.
|
||||
allow if {
|
||||
input.policy_chain.has_any_file
|
||||
not any_deny_match
|
||||
any_allow_match
|
||||
}
|
||||
|
||||
# Any deny pattern at ANY level matches the email.
|
||||
any_deny_match if {
|
||||
some level in input.policy_chain.levels
|
||||
some pattern in level.acl.deny
|
||||
email_matches(pattern, input.user.email)
|
||||
}
|
||||
|
||||
# Any allow pattern at ANY level matches the email.
|
||||
any_allow_match if {
|
||||
some level in input.policy_chain.levels
|
||||
some pattern in level.acl.allow
|
||||
email_matches(pattern, input.user.email)
|
||||
}
|
||||
|
||||
# email_matches: identical to access.rego — see that file for the
|
||||
# rationale on the four cases. Duplicated rather than imported so this
|
||||
# file is self-contained for operators who copy it as a starting point.
|
||||
|
||||
email_matches(pattern, email) if {
|
||||
pattern == email
|
||||
}
|
||||
|
||||
email_matches(pattern, email) if {
|
||||
pattern == "*"
|
||||
email != ""
|
||||
}
|
||||
|
||||
email_matches(pattern, email) if {
|
||||
contains(pattern, "*")
|
||||
contains(pattern, "@")
|
||||
glob.match(pattern, ["@"], email)
|
||||
}
|
||||
|
||||
email_matches(pattern, email) if {
|
||||
contains(pattern, "*")
|
||||
not contains(pattern, "@")
|
||||
pattern != "*"
|
||||
glob.match(pattern, [], email)
|
||||
}
|
||||
|
|
@ -38,6 +38,32 @@ func TLSConfig(cfg config.Config) (*tls.Config, bool, error) {
|
|||
return &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
// NIST SP 800-52 Rev. 2 conformant cipher allowlist for TLS 1.2.
|
||||
// (TLS 1.3 ciphers are not operator-selectable in Go's stdlib —
|
||||
// the runtime picks from a fixed set of AEAD suites; that's fine
|
||||
// because all of them meet the federal bar.) Order matters when
|
||||
// preferServerCipherSuites was respected by clients; modern Go
|
||||
// uses the runtime's own preference, but the explicit list still
|
||||
// drops every weak suite a client might offer.
|
||||
// AES-128-GCM is listed before AES-256-GCM because hardware
|
||||
// AES-NI makes the 128-bit suite measurably faster with no
|
||||
// security-margin compromise (NIST allows both).
|
||||
CipherSuites: []uint16{
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
|
||||
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
|
||||
},
|
||||
// NIST SP 800-52 Rev. 2 § 3.3.2: X25519, P-256, P-384.
|
||||
// X25519 first — fastest modern curve, no known weaknesses;
|
||||
// the NIST P-curves follow for clients that don't support it.
|
||||
CurvePreferences: []tls.CurveID{
|
||||
tls.X25519,
|
||||
tls.CurveP256,
|
||||
tls.CurveP384,
|
||||
},
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
102
zddc/internal/tlsutil/selfsigned_test.go
Normal file
102
zddc/internal/tlsutil/selfsigned_test.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package tlsutil
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"testing"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||
)
|
||||
|
||||
// TestTLSConfig_NoneMode: TLSMode=="none" returns no config and useTLS=false.
|
||||
func TestTLSConfig_NoneMode(t *testing.T) {
|
||||
tlsCfg, useTLS, err := TLSConfig(config.Config{TLSMode: "none"})
|
||||
if err != nil {
|
||||
t.Fatalf("TLSConfig(none): %v", err)
|
||||
}
|
||||
if useTLS {
|
||||
t.Errorf("useTLS = true, want false for TLSMode=none")
|
||||
}
|
||||
if tlsCfg != nil {
|
||||
t.Errorf("tlsCfg = %+v, want nil for TLSMode=none", tlsCfg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTLSConfig_SelfSignedHardenedDefaults: the self-signed path returns a
|
||||
// config that conforms to NIST SP 800-52 Rev. 2 — TLS 1.2 minimum, the
|
||||
// AEAD-only cipher allowlist, and the X25519/P-256/P-384 curve list.
|
||||
func TestTLSConfig_SelfSignedHardenedDefaults(t *testing.T) {
|
||||
tlsCfg, useTLS, err := TLSConfig(config.Config{TLSMode: "selfsigned"})
|
||||
if err != nil {
|
||||
t.Fatalf("TLSConfig(selfsigned): %v", err)
|
||||
}
|
||||
if !useTLS {
|
||||
t.Fatal("useTLS = false, want true")
|
||||
}
|
||||
if tlsCfg == nil {
|
||||
t.Fatal("tlsCfg = nil")
|
||||
}
|
||||
|
||||
if tlsCfg.MinVersion != tls.VersionTLS12 {
|
||||
t.Errorf("MinVersion = %#x, want TLS 1.2 (%#x)", tlsCfg.MinVersion, tls.VersionTLS12)
|
||||
}
|
||||
|
||||
wantCiphers := map[uint16]bool{
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256: true,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384: true,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256: true,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384: true,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305: true,
|
||||
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305: true,
|
||||
}
|
||||
if len(tlsCfg.CipherSuites) != len(wantCiphers) {
|
||||
t.Errorf("CipherSuites count = %d, want %d", len(tlsCfg.CipherSuites), len(wantCiphers))
|
||||
}
|
||||
for _, c := range tlsCfg.CipherSuites {
|
||||
if !wantCiphers[c] {
|
||||
t.Errorf("CipherSuites contains unexpected suite %#x; allowlist is the NIST SP 800-52 Rev. 2 set", c)
|
||||
}
|
||||
}
|
||||
|
||||
wantCurves := map[tls.CurveID]bool{
|
||||
tls.X25519: true,
|
||||
tls.CurveP256: true,
|
||||
tls.CurveP384: true,
|
||||
}
|
||||
if len(tlsCfg.CurvePreferences) != len(wantCurves) {
|
||||
t.Errorf("CurvePreferences count = %d, want %d", len(tlsCfg.CurvePreferences), len(wantCurves))
|
||||
}
|
||||
for _, c := range tlsCfg.CurvePreferences {
|
||||
if !wantCurves[c] {
|
||||
t.Errorf("CurvePreferences contains unexpected curve %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
if len(tlsCfg.Certificates) != 1 {
|
||||
t.Errorf("Certificates count = %d, want 1", len(tlsCfg.Certificates))
|
||||
}
|
||||
}
|
||||
|
||||
// TestTLSConfig_NoWeakCiphers: the allowlist must not include any of the
|
||||
// federally-deprecated suites — CBC-mode, RC4, 3DES, SHA-1, NULL, EXPORT.
|
||||
// This is a guardrail against accidental regressions if the list is edited.
|
||||
func TestTLSConfig_NoWeakCiphers(t *testing.T) {
|
||||
tlsCfg, _, err := TLSConfig(config.Config{TLSMode: "selfsigned"})
|
||||
if err != nil {
|
||||
t.Fatalf("TLSConfig: %v", err)
|
||||
}
|
||||
weak := map[uint16]string{
|
||||
tls.TLS_RSA_WITH_AES_128_CBC_SHA: "AES-128-CBC-SHA (CBC mode)",
|
||||
tls.TLS_RSA_WITH_AES_256_CBC_SHA: "AES-256-CBC-SHA (CBC mode)",
|
||||
tls.TLS_RSA_WITH_AES_128_GCM_SHA256: "RSA-AES-128-GCM (no forward secrecy)",
|
||||
tls.TLS_RSA_WITH_AES_256_GCM_SHA384: "RSA-AES-256-GCM (no forward secrecy)",
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA: "ECDHE-RSA-AES-128-CBC-SHA (CBC mode)",
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA: "ECDHE-RSA-AES-256-CBC-SHA (CBC mode)",
|
||||
tls.TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA: "3DES",
|
||||
tls.TLS_RSA_WITH_RC4_128_SHA: "RC4",
|
||||
}
|
||||
for _, c := range tlsCfg.CipherSuites {
|
||||
if name, bad := weak[c]; bad {
|
||||
t.Errorf("CipherSuites includes federally-deprecated suite: %s (%#x)", name, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue