From d052ca95657d6789e6f2f8f0cbda43218b9a911a Mon Sep 17 00:00:00 2001 From: Bruno Carvalho Date: Sat, 13 Jun 2026 13:05:45 -0300 Subject: [PATCH 1/6] feat(audit): add gowdk audit security posture and baseline policy engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the first slice of the M8 declarative security audit framework: a single, auditable view of an app's security posture plus a command that checks it against a built-in baseline. - internal/securitymanifest: pure IR-derived posture (gowdk-security.json) of every route, backend endpoint, and contract — guards, CSRF, body limit, public/default-deny, and source location. Describes, never enforces. - internal/auditspec: composable policy model (named policies, extends, route/endpoint/frontend selectors), the evaluation engine, and a built-in baseline that encodes the production-readiness gates in security.md (actions require CSRF, no public-by-omission APIs, no guardless endpoints). - cmd/gowdk audit: derives the posture, applies the baseline, prints findings (human + --json) with code, file:line, and remediation, and exits non-zero on error findings to gate CI. It is standalone; gowdk build never runs it. - buildgen: emit gowdk-security.json alongside the route/asset manifests on the disk, memory, and incremental build paths. - diagnostics: register experimental audit_* and policy_* codes with gowdk explain details. Severity lives only in the registry. Also skip .claude (nested git worktrees) in the diagnostics completeness scan and the contract scanner, so whole-tree tooling does not double-count a sibling worktree's diagnostic codes and contract registrations. Tests: securitymanifest projection, auditspec engine/baseline/composition/ selectors, and audit CLI (clean pass + missing-CSRF failure with non-zero exit). Refs #179, #182, #120, #119. Spec: docs/product/security-audit-spec.md --- cmd/gowdk/audit.go | 133 ++++++ cmd/gowdk/audit_test.go | 87 ++++ cmd/gowdk/main.go | 3 + docs/compiler/manifest.md | 41 ++ docs/engineering/security.md | 16 + docs/product/security-audit-spec.md | 103 +++++ docs/reference/cli.md | 28 +- docs/reference/diagnostic-codes.md | 11 + internal/auditspec/auditspec.go | 129 ++++++ internal/auditspec/baseline.go | 55 +++ internal/auditspec/engine.go | 484 +++++++++++++++++++++ internal/auditspec/engine_test.go | 193 ++++++++ internal/buildgen/build.go | 19 + internal/buildgen/buildgen.go | 2 + internal/buildgen/security_manifest.go | 31 ++ internal/buildgen/types.go | 19 +- internal/contractscan/ast_helpers.go | 5 +- internal/diagnostics/explain.go | 122 ++++++ internal/diagnostics/registry.go | 18 + internal/diagnostics/registry_test.go | 5 +- internal/securitymanifest/manifest.go | 204 +++++++++ internal/securitymanifest/manifest_test.go | 97 +++++ 22 files changed, 1791 insertions(+), 14 deletions(-) create mode 100644 cmd/gowdk/audit.go create mode 100644 cmd/gowdk/audit_test.go create mode 100644 docs/product/security-audit-spec.md create mode 100644 internal/auditspec/auditspec.go create mode 100644 internal/auditspec/baseline.go create mode 100644 internal/auditspec/engine.go create mode 100644 internal/auditspec/engine_test.go create mode 100644 internal/buildgen/security_manifest.go create mode 100644 internal/securitymanifest/manifest.go create mode 100644 internal/securitymanifest/manifest_test.go diff --git a/cmd/gowdk/audit.go b/cmd/gowdk/audit.go new file mode 100644 index 00000000..6132c663 --- /dev/null +++ b/cmd/gowdk/audit.go @@ -0,0 +1,133 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/cssbruno/gowdk/internal/auditspec" + "github.com/cssbruno/gowdk/internal/lang" + "github.com/cssbruno/gowdk/internal/securitymanifest" +) + +const auditUsage = "usage: gowdk audit [--config ] [--module ] [--ssr] [--json] [files...]" + +// auditReport is the gowdk audit result: the derived security posture plus the +// findings from evaluating the built-in baseline (and, later, declared +// policies) against it. +type auditReport struct { + Version int `json:"version"` + Status string `json:"status"` + Summary auditSummary `json:"summary"` + Findings []auditspec.Finding `json:"findings"` + Manifest securitymanifest.SecurityManifest `json:"manifest"` +} + +type auditSummary struct { + Routes int `json:"routes"` + Endpoints int `json:"endpoints"` + Contracts int `json:"contracts"` + Errors int `json:"errors"` + Warnings int `json:"warnings"` + Info int `json:"info"` +} + +type auditExitError struct { + errors int +} + +func (err auditExitError) Error() string { + return fmt.Sprintf("audit found %d error finding(s)", err.errors) +} + +func (auditExitError) SilentCLIError() {} + +// audit derives the security posture from validated IR, evaluates the baseline +// policy against it, and reports findings. It is a standalone command: gowdk +// build never runs it, so it can never fail a build implicitly. It exits +// non-zero when any error-severity finding exists so it can gate CI. +func audit(args []string) error { + options, paths, err := loadCommandInputs(args, "audit", true) + if err != nil { + return err + } + + checked, diagnostics := lang.CheckFilesWithOptions(options.Config, paths, lang.CheckOptions{ProjectRoot: options.ProjectRoot}) + for _, diagnostic := range diagnostics { + fmt.Fprintln(os.Stderr, diagnostic.String()) + } + if diagnostics.HasErrors() { + return fmt.Errorf("audit failed: source has validation errors") + } + + ir := checked.IR + if err := linkIRContractReferences(&ir, options.ProjectRoot); err != nil { + return err + } + + manifest := securitymanifest.Build(options.Config, ir) + findings := auditspec.Evaluate(manifest, auditspec.Baseline()) + auditspec.SortFindings(findings) + report := buildAuditReport(manifest, findings) + + if options.JSON { + payload, err := json.MarshalIndent(report, "", " ") + if err != nil { + return err + } + fmt.Println(string(payload)) + } else { + printAuditReport(report) + } + + if report.Summary.Errors > 0 { + return auditExitError{errors: report.Summary.Errors} + } + return nil +} + +func buildAuditReport(manifest securitymanifest.SecurityManifest, findings []auditspec.Finding) auditReport { + summary := auditspec.Summarize(findings) + if findings == nil { + findings = []auditspec.Finding{} + } + return auditReport{ + Version: 1, + Status: auditspec.Status(summary), + Summary: auditSummary{ + Routes: len(manifest.Routes), + Endpoints: len(manifest.Endpoints), + Contracts: len(manifest.Contracts), + Errors: summary.Errors, + Warnings: summary.Warnings, + Info: summary.Info, + }, + Findings: findings, + Manifest: manifest, + } +} + +func printAuditReport(report auditReport) { + fmt.Printf("GOWDK audit: %s\n", strings.ToUpper(report.Status)) + fmt.Printf("Posture: %d route(s), %d endpoint(s), %d contract(s)\n", report.Summary.Routes, report.Summary.Endpoints, report.Summary.Contracts) + fmt.Printf("Findings: %d error(s), %d warning(s), %d info\n", report.Summary.Errors, report.Summary.Warnings, report.Summary.Info) + if len(report.Findings) == 0 { + fmt.Println("No policy findings. Posture matches the security baseline.") + return + } + for _, finding := range report.Findings { + location := finding.Target + if finding.Source != "" { + location = finding.Source + } + fmt.Printf("[%s] %s: %s\n", strings.ToUpper(string(finding.Severity)), finding.Code, finding.Message) + if location != "" { + fmt.Printf(" at: %s\n", location) + } + if finding.Remediation != "" { + fmt.Printf(" fix: %s\n", finding.Remediation) + } + fmt.Printf(" why: gowdk explain %s\n", finding.Code) + } +} diff --git a/cmd/gowdk/audit_test.go b/cmd/gowdk/audit_test.go new file mode 100644 index 00000000..35edf684 --- /dev/null +++ b/cmd/gowdk/audit_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "encoding/json" + "path/filepath" + "testing" +) + +func TestAuditCommandPassesCleanProject(t *testing.T) { + root := t.TempDir() + config := writeMinimalCLIConfig(t, root) + writeCLIFile(t, filepath.Join(root, "home.page.gwdk"), `package app + +page home +route "/" + +view { +
Home
+} +`) + + stdout, _, err := captureCLIOutput(t, func() error { + return run([]string{"audit", "--json", "--config", config, filepath.Join(root, "home.page.gwdk")}) + }) + if err != nil { + t.Fatalf("expected clean audit to succeed: %v", err) + } + + var report auditReport + if err := json.Unmarshal([]byte(stdout), &report); err != nil { + t.Fatalf("expected JSON audit output, got %q: %v", stdout, err) + } + if report.Version != 1 || report.Status != "ok" { + t.Fatalf("unexpected audit report: status=%q version=%d", report.Status, report.Version) + } + if report.Summary.Errors != 0 || len(report.Findings) != 0 { + t.Fatalf("expected no findings for a clean project: %#v", report.Findings) + } + if report.Summary.Routes != 1 { + t.Fatalf("expected one route in posture, got %d", report.Summary.Routes) + } +} + +func TestAuditCommandFlagsMissingCSRFAndExitsNonZero(t *testing.T) { + root := t.TempDir() + config := writeMinimalCLIConfig(t, root) + // writeCLIFile injects `guard public`, and the minimal config leaves CSRF + // disabled, so the action endpoint must trip audit_action_missing_csrf. + writeCLIFile(t, filepath.Join(root, "signup.page.gwdk"), `package app + +page signup +route "/signup" + +act Submit POST "/submit" + +view { +
Sign up
+} +`) + + stdout, _, err := captureCLIOutput(t, func() error { + return run([]string{"audit", "--json", "--config", config, filepath.Join(root, "signup.page.gwdk")}) + }) + if err == nil { + t.Fatal("expected non-zero exit when an error finding exists") + } + if _, silent := err.(interface{ SilentCLIError() }); !silent { + t.Fatalf("audit error should be a silent CLI error, got %T", err) + } + + var report auditReport + if err := json.Unmarshal([]byte(stdout), &report); err != nil { + t.Fatalf("expected JSON audit output, got %q: %v", stdout, err) + } + if report.Status != "fail" || report.Summary.Errors == 0 { + t.Fatalf("expected a failing audit, got status=%q errors=%d", report.Status, report.Summary.Errors) + } + found := false + for _, finding := range report.Findings { + if finding.Code == "audit_action_missing_csrf" { + found = true + } + } + if !found { + t.Fatalf("expected audit_action_missing_csrf finding, got %#v", report.Findings) + } +} diff --git a/cmd/gowdk/main.go b/cmd/gowdk/main.go index 050dcb4a..33417538 100644 --- a/cmd/gowdk/main.go +++ b/cmd/gowdk/main.go @@ -64,6 +64,8 @@ func run(args []string) error { return explainDiagnostic(args[1:]) case "doctor": return doctor(args[1:]) + case "audit": + return audit(args[1:]) case "contracts": return contractsReport(args[1:]) case "graph": @@ -126,6 +128,7 @@ func usage() { fmt.Println(" generate stubs [--config ] [--module ] [--ssr] [files...] write missing action/API Go handler stubs") fmt.Println(" explain [--json] explain a diagnostic code and next steps") fmt.Println(" doctor [--config ] [--module ] [--ssr] [--json] [files...] check local GOWDK environment and project health") + fmt.Println(" audit [--config ] [--module ] [--ssr] [--json] [files...] check security posture against the baseline policy") fmt.Println(" contracts [--json] [dir] print Go contract registration metadata") fmt.Println(" graph [--json] [dir] print command/event contract graph") fmt.Println(" trace [--json] [dir] print one command/query/event/job contract trace") diff --git a/docs/compiler/manifest.md b/docs/compiler/manifest.md index cb6a62b3..3cd3b792 100644 --- a/docs/compiler/manifest.md +++ b/docs/compiler/manifest.md @@ -158,6 +158,47 @@ include route HTML paths such as `index.html`; those route entries do not need to appear in `files`. Configured stylesheet links are not included unless GOWDK emits the referenced file. +## Current Security Manifest + +`gowdk build` also writes `gowdk-security.json` in the selected output +directory. It is a declarative, IR-derived security posture: every route, +backend endpoint, and contract with its guards, CSRF state, body limit, public/ +default-deny classification, and source location, plus a `frontend` surface +block. Like the route and asset manifests, it is pure data — it never evaluates +policy. `gowdk audit` reads this same posture and applies the security baseline +(and, in later M8 phases, declared policies) to produce findings. + +```json +{ + "version": 1, + "generatedFrom": "ir", + "endpoints": [ + { + "id": "Submit", + "kind": "action", + "method": "POST", + "path": "/submit", + "guards": ["public"], + "csrf": false, + "bodyLimitBytes": 1048576, + "public": true, + "defaultDeny": false, + "pageId": "signup", + "source": "signup.page.gwdk:8" + } + ], + "frontend": { + "unguardedRoutes": [], + "bundleSecrets": [], + "rawHtmlSinks": [], + "configuredHeaders": [] + } +} +``` + +`version` is the security manifest schema version. The `frontend` block is +populated by the frontend audits in later M8 phases. + ## Planned Manifest Work Future manifest versions need full action/API metadata, transitive diff --git a/docs/engineering/security.md b/docs/engineering/security.md index 9d44ebe4..6e9879a1 100644 --- a/docs/engineering/security.md +++ b/docs/engineering/security.md @@ -48,6 +48,22 @@ Before generated app output is considered production-ready: - Embedded asset selection must exclude secrets, local env files, private source files, and temporary artifacts. - Diagnostics and logs must avoid printing sensitive form values, credentials, or private build-time data. +## Auditing The Posture + +`gowdk audit` makes this baseline executable. It derives a declarative security +posture from validated IR (written as `gowdk-security.json` at build time) and +evaluates a built-in policy that encodes the production-readiness gates above — +for example, actions must enforce CSRF and APIs must not be public by omission. +Findings carry a stable diagnostic code, a `file:line`, and remediation; run +`gowdk explain ` for details. + +`gowdk audit` is a standalone command. `gowdk build` never runs it, so it cannot +fail a build implicitly; run it on demand or in CI, where its non-zero exit on +error findings gates the pipeline. It is the auditable, human- and +LLM-readable view of how close generated output is to these gates. Later M8 +phases add frontend audits, declared `*.audit.gwdk` policies, and an +integration-test runner. + ## Security Review Triggers Perform a focused security review when adding: diff --git a/docs/product/security-audit-spec.md b/docs/product/security-audit-spec.md new file mode 100644 index 00000000..1aacb6ef --- /dev/null +++ b/docs/product/security-audit-spec.md @@ -0,0 +1,103 @@ +# Feature Spec: Declarative Security Audit (M8) + +## Problem + +GOWDK enforces security in scattered places — default-deny guards, opt-in CSRF, +body caps, panic boundaries, secret redaction, and the diagnostic registry — but +there is no single, declarative, auditable view of an app's whole security +posture, no way to declare the intended posture and have it checked, and no +integration-test framework that proves the runtime behaves as declared. The +production-readiness gates in `docs/engineering/security.md` exist only as prose. +Teams (and LLMs reviewing a change) cannot answer "is this app's security +posture acceptable?" from one artifact. + +## Goals + +- A declarative, machine- and human-readable security posture derived from the + IR (`gowdk-security.json`), covering routes, backend endpoints, contracts, and + the frontend surface. +- A `gowdk audit` command that evaluates a built-in baseline (the documented + production-readiness gates, made executable) against that posture and reports + registry-coded findings with `file:line` and remediation. +- Composable, declared policies in a new `*.audit.gwdk` file kind that extend or + override the baseline (named policies, `extends`, selector-applied to many + targets). +- Frontend coverage: secret/data-leak scan of embedded output, client route-guard + coverage, required response headers/CSP, and raw-HTML (XSS) sink allowlisting. +- An integration-test framework (`test {}` blocks → generated `httptest` tests) + that verifies the runtime matches the declared posture. + +## Non-Goals + +- Owning authentication, sessions, RBAC storage, or backend resource + authorization — those stay in app Go. Guards and audits are defense-in-depth. +- Running the audit as part of `gowdk build`. The audit is always a separate, + explicit command so it can never fail a build implicitly. +- Browser/E2E testing or testing user domain logic. +- Full data-flow/taint analysis of raw-HTML sinks (M8 flags sinks; it does not + trace tainted inputs). + +## Users And Permissions + +- Primary users: app authors and reviewers (human or LLM) who need a trustworthy, + one-glance security posture and a CI gate. +- The audit reads guard/CSRF/body-limit/contract-role metadata already in the IR; + it grants no access and changes no runtime behavior. + +## Trust Model + +Three-way consistency: declared policy ⟷ static posture (from IR) ⟷ runtime +behavior (integration tests) must agree. `gowdk audit` checks policy-vs-static; +`gowdk audit --run` adds runtime-vs-declared. Severity for every finding comes +only from the diagnostic registry, so the baseline never hardcodes severity. + +## Anti-Magic Guarantees + +- The posture manifest is a pure projection (like `gowdk-routes.json`); it + describes, never acts. +- `gowdk audit` is explicit and never part of `gowdk build`. +- Every finding cites a named rule, a diagnostic code, and a source `file:line`; + `gowdk explain ` gives the reasoning. +- The baseline is the gates already written in `security.md`, made executable — + not new hidden policy. +- Integration tests are emitted as readable `_test.go` files the user owns; the + `--run` convenience generates-and-runs them but writes the same readable file. + +## Acceptance Criteria + +- [x] `gowdk-security.json` is emitted at build time and by `gowdk audit --json`. +- [x] `gowdk audit` applies the baseline, cites findings by code + `file:line`, + and exits non-zero on error findings. +- [x] New `audit_*` / `policy_*` codes are registered and `gowdk explain`-able. +- [ ] Frontend audits (secret leak, route-guard coverage, headers/CSP, raw-HTML). +- [ ] `*.audit.gwdk` parser → IR → composable policy engine (`extends`, selectors). +- [ ] `test {}` blocks → generated `httptest` tests; `gowdk audit --emit-tests` + and `--run`; `runtime/app` security-header capability. + +## Delivery Phases + +- **Phase 0–1 (shipped in this slice):** diagnostic codes; `internal/auditspec` + (composable policy model, selector matcher, `extends`, baseline, engine); + `internal/securitymanifest` (IR → posture); `gowdk audit` with the baseline; + `gowdk-security.json` at build time. Unit + CLI tests. +- **Phase 2:** the four frontend audits as baseline rules. +- **Phase 3:** the `*.audit.gwdk` file kind and declared composable policies. +- **Phase 4:** `runtime/testkit`, generated `_test.go`, `--emit-tests`/`--run`, + and the `runtime/app` security-header capability. + +## Issue Alignment + +Advances/closes #179 (IR-driven test harness; Phase 4 testkit). Relates to #120 +(CSRF tests), #119 (fail-closed secret), #67 (testing umbrella), #182 (features +from IR metadata), and diagnostics issues #328/#255/#107/#109. + +## Verification + +```sh +go build ./cmd/gowdk +go test ./internal/securitymanifest ./internal/auditspec ./cmd/gowdk ./internal/diagnostics +go run ./cmd/gowdk audit --json --config gowdk.config.go +go run ./cmd/gowdk explain audit_api_public_by_omission +go run ./cmd/gowdk build --out /tmp/gowdk-build examples/pages/home.page.gwdk \ + examples/pages/hero.cmp.gwdk && test -f /tmp/gowdk-build/gowdk-security.json +``` diff --git a/docs/reference/cli.md b/docs/reference/cli.md index b45a8065..1ea0967a 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -23,6 +23,7 @@ gowdk inspect ir|tree|endpoint-graph|go-bindings [--config ] [--module ] [--module ] [--ssr] [files...] gowdk explain [--json] gowdk doctor [--config ] [--module ] [--ssr] [--json] [files...] +gowdk audit [--config ] [--module ] [--ssr] [--json] [files...] gowdk contracts [--json] [dir] gowdk graph [--json] [dir] gowdk trace [--json] [dir] @@ -41,7 +42,7 @@ gowdk lsp [--ssr] - `--tests`: supported by `init`; adds `tests/gowdk_smoke_test.go`, an optional generated app smoke test that runs only when `GOWDK_BIN` points at a built `gowdk` CLI. - `--template`: supported by `init`; selects `site` or `minimal`. Defaults to `site`. - `--list`: supported by `add`; prints built-in addon names the command can wire. -- `--json`: supported by `check`, `doctor`, `explain`, `inspect`, `contracts`, `graph`, `trace`, and `list`; prints +- `--json`: supported by `check`, `doctor`, `audit`, `explain`, `inspect`, `contracts`, `graph`, `trace`, and `list`; prints editor/tooling-friendly JSON. Contract JSON includes same-file handler signature diagnostics when available. `gowdk check --json` uses diagnostic schema version `1`. `gowdk inspect` emits JSON by default; `--json` is @@ -50,12 +51,19 @@ gowdk lsp [--ssr] diagnostics are present. - `gowdk doctor --json`: prints a versioned health report with overall status, summary counts, environment metadata, and check records. +- `gowdk audit`: derives the security posture from validated IR, evaluates the + built-in security baseline against it, and reports findings. It is a separate + command — `gowdk build` never runs it — so it cannot fail a build implicitly. + It exits non-zero when any error-severity finding exists, so it can gate CI. + `--json` prints the posture manifest plus findings and a summary. Every finding + carries a diagnostic code; run `gowdk explain ` for details. The posture + alone is also written to `gowdk-security.json` at build time. - `--write`: supported by `fmt`; overwrites formatted files. - `--dry-run`: supported by `fix`; prints files with available registered fixes without writing changes. - `--code`: supported by `fix`; limits rewrites to one diagnostic code that has a registered fix. -- `--config`: supported by `add`, `check`, `doctor`, `manifest`, `sitemap`, +- `--config`: supported by `add`, `check`, `doctor`, `audit`, `manifest`, `sitemap`, `routes`, `endpoints`, `inspect`, `generate stubs`, and `build`; selects the config file. Compile commands load a literal config subset from the given path instead of the required default `gowdk.config.go`. @@ -72,7 +80,7 @@ gowdk lsp [--ssr] command owners. - `--allow-missing-backend`: supported by `build` and forwarded by `dev`; in production mode, allows missing or unsupported action/API handlers to generate HTTP 501 stubs instead of failing the build. - `--target`: supported by `build`; may be repeated or comma-separated, and runs selected `Build.Targets` entries. -- `--module`: supported by `check`, `doctor`, `manifest`, `sitemap`, `routes`, +- `--module`: supported by `check`, `doctor`, `audit`, `manifest`, `sitemap`, `routes`, `endpoints`, `inspect`, `generate stubs`, and `build`; may be repeated or comma-separated, and limits discovery to selected configured modules when no explicit file list is passed. @@ -102,6 +110,8 @@ go run ./cmd/gowdk fix --dry-run --code old_action_block_syntax --config gowdk.c go run ./cmd/gowdk check --ssr examples/ssr/dashboard.page.gwdk go run ./cmd/gowdk explain missing_ssr_addon go run ./cmd/gowdk explain --json spa_dynamic_route_missing_paths +go run ./cmd/gowdk audit --config gowdk.config.go +go run ./cmd/gowdk audit --json --ssr --config gowdk.config.go go run ./cmd/gowdk doctor go run ./cmd/gowdk doctor --json go run ./cmd/gowdk doctor --module frontend --ssr @@ -211,6 +221,18 @@ optional tools such as Tailwind or Node. Missing config and language failures are errors. Missing optional tools are warnings when the project appears to use them. The command exits non-zero only when at least one check is an error. +`audit` reports the security posture, not the environment. It derives a +declarative posture from validated IR — every route, backend endpoint, and +contract with its guards, CSRF state, body limit, and source location — and +evaluates the built-in security baseline against it. The baseline encodes the +production-readiness gates from `docs/engineering/security.md` (for example: +actions must enforce CSRF, APIs must not be public by omission). Findings carry +a diagnostic code, a `file:line`, and remediation; run `gowdk explain ` +for details. `audit` never runs as part of `gowdk build`, so it cannot fail a +build implicitly; run it on demand or wire it into CI, where its non-zero exit +on error findings gates the pipeline. The posture alone is also emitted as +`gowdk-security.json` by `gowdk build`. + `--wasm` produces a Go `js/wasm` compile artifact from the generated app. This is a deploy artifact for hosts that can run Go WebAssembly; it is separate from component-level browser island assets emitted for `wasm` components. diff --git a/docs/reference/diagnostic-codes.md b/docs/reference/diagnostic-codes.md index db9aea52..5b213186 100644 --- a/docs/reference/diagnostic-codes.md +++ b/docs/reference/diagnostic-codes.md @@ -155,6 +155,17 @@ Parser diagnostics emit stable codes for common unsupported syntax and keep `client_go_block_wasm_entrypoint_error`, `client_go_block_wasm_import_error`, `client_go_block_wasm_export_error`. +- Security audit (`gowdk audit`): `audit_action_missing_csrf`, + `audit_api_public_by_omission`, `audit_guardless_endpoint_page`, + `audit_bundle_secret`, `audit_client_route_unguarded`, + `audit_headers_missing`, `audit_headers_runtime_missing`, + `audit_raw_html_sink`, `audit_max_body_exceeds_policy`, + `audit_public_not_allowed`, `audit_required_guard_missing`, + `audit_runtime_mismatch`, `audit_test_failed`, `policy_duplicate_name`, + `policy_extends_cycle`, `policy_unknown_extends`, + `policy_unknown_selector`, `policy_selector_matched_nothing`. These are + experimental; some are emitted by later M8 phases (frontend audits, + declared policies, and the `--run` test runner). ## Adding A Code diff --git a/internal/auditspec/auditspec.go b/internal/auditspec/auditspec.go new file mode 100644 index 00000000..c9f9336e --- /dev/null +++ b/internal/auditspec/auditspec.go @@ -0,0 +1,129 @@ +// Package auditspec is the policy model and evaluation engine for gowdk audit. +// +// A Policy is a named, composable set of Rules applied to targets (routes, +// endpoints, contracts, or the frontend surface) selected by Selectors. The +// built-in Baseline encodes the production-readiness gates from +// docs/engineering/security.md; declared *.audit.gwdk policies extend or +// override it. Evaluate matches the policies against a securitymanifest posture +// and returns registry-coded Findings; it never decides severity — that comes +// only from internal/diagnostics. +package auditspec + +import "github.com/cssbruno/gowdk/internal/diagnostics" + +// SelectorKind classifies a policy target selector. +type SelectorKind string + +const ( + SelectorRoute SelectorKind = "route" + SelectorEndpoint SelectorKind = "endpoint" + SelectorFrontend SelectorKind = "frontend" + SelectorUnknown SelectorKind = "unknown" +) + +// RuleKind classifies one policy rule. +type RuleKind string + +const ( + // RuleRequireCSRF requires a matched endpoint to enforce CSRF. + RuleRequireCSRF RuleKind = "require_csrf" + // RuleRequireAnyGuard requires a matched target to state access (any guard, + // including guard public) rather than be denied by omission. + RuleRequireAnyGuard RuleKind = "require_any_guard" + // RuleRequireGuard requires a specific guard ID (for example role:admin). + RuleRequireGuard RuleKind = "require_guard" + // RuleDenyPublic forbids guard public on a matched target. + RuleDenyPublic RuleKind = "deny_public" + // RuleMaxBody caps a matched endpoint's request body limit. + RuleMaxBody RuleKind = "max_body" + // RuleRequireHeader requires the app to be configured to emit a response + // header. + RuleRequireHeader RuleKind = "require_header" + // RuleNoSecretsInBundle forbids secret-shaped values in embedded output. + RuleNoSecretsInBundle RuleKind = "no_secrets_in_bundle" + // RuleAllowRawHTML allowlists one raw-HTML sink (source:field); every sink + // not allowlisted is reported. + RuleAllowRawHTML RuleKind = "allow_raw_html" +) + +// Selector targets a set of routes, endpoints, or the frontend surface. +type Selector struct { + Raw string + Kind SelectorKind +} + +// Rule is one policy constraint. Code is the diagnostic code emitted when the +// rule is violated; Value carries the rule argument (a guard ID, header name, +// byte size, or allowlist entry) when the rule kind needs one. +type Rule struct { + Kind RuleKind + Value string + Code string +} + +// Policy is a named, composable set of rules applied to selected targets. +type Policy struct { + Name string + Extends []string + Selectors []Selector + Rules []Rule + Source string + Builtin bool +} + +// Finding is one policy violation or policy-resolution error. +type Finding struct { + Code string `json:"code"` + Severity diagnostics.Severity `json:"severity"` + Target string `json:"target,omitempty"` + Policy string `json:"policy,omitempty"` + Rule string `json:"rule,omitempty"` + Message string `json:"message"` + Source string `json:"source,omitempty"` + Remediation string `json:"remediation,omitempty"` +} + +// Summary counts findings by severity. +type Summary struct { + Errors int `json:"errors"` + Warnings int `json:"warnings"` + Info int `json:"info"` +} + +// Summarize counts findings by their registry severity. +func Summarize(findings []Finding) Summary { + var summary Summary + for _, finding := range findings { + switch finding.Severity { + case diagnostics.SeverityError: + summary.Errors++ + case diagnostics.SeverityWarning: + summary.Warnings++ + default: + summary.Info++ + } + } + return summary +} + +// Status reports "fail" when any error finding exists, "warning" when only +// warnings exist, and "ok" otherwise. +func Status(summary Summary) string { + switch { + case summary.Errors > 0: + return "fail" + case summary.Warnings > 0: + return "warning" + default: + return "ok" + } +} + +// severityFor resolves a code's severity from the diagnostic registry, defaulting +// to error for unknown codes so a missing registration is loud, not silent. +func severityFor(code string) diagnostics.Severity { + if severity, ok := diagnostics.DefaultSeverity(code); ok { + return severity + } + return diagnostics.SeverityError +} diff --git a/internal/auditspec/baseline.go b/internal/auditspec/baseline.go new file mode 100644 index 00000000..46948225 --- /dev/null +++ b/internal/auditspec/baseline.go @@ -0,0 +1,55 @@ +package auditspec + +// Baseline returns the built-in policy set that gowdk audit applies with zero +// configuration. It encodes the production-readiness gates from +// docs/engineering/security.md and docs/engineering/security-threat-model.md so +// security is enforced by default, not by opt-in. Declared *.audit.gwdk policies +// extend or override these via matching selectors and rules. +// +// Severity is never set here; each rule references a registry code and the +// engine resolves severity from internal/diagnostics. +func Baseline() []Policy { + return []Policy{ + { + Name: "baseline.actions", + Builtin: true, + Selectors: []Selector{ + {Raw: "act:*", Kind: SelectorEndpoint}, + }, + Rules: []Rule{ + {Kind: RuleRequireCSRF, Code: "audit_action_missing_csrf"}, + {Kind: RuleRequireAnyGuard, Code: "audit_guardless_endpoint_page"}, + }, + }, + { + Name: "baseline.fragments", + Builtin: true, + Selectors: []Selector{ + {Raw: "fragment:*", Kind: SelectorEndpoint}, + }, + Rules: []Rule{ + {Kind: RuleRequireAnyGuard, Code: "audit_guardless_endpoint_page"}, + }, + }, + { + Name: "baseline.api", + Builtin: true, + Selectors: []Selector{ + {Raw: "api:*", Kind: SelectorEndpoint}, + }, + Rules: []Rule{ + {Kind: RuleRequireAnyGuard, Code: "audit_api_public_by_omission"}, + }, + }, + { + Name: "baseline.frontend", + Builtin: true, + Selectors: []Selector{ + {Raw: "frontend", Kind: SelectorFrontend}, + }, + Rules: []Rule{ + {Kind: RuleNoSecretsInBundle, Code: "audit_bundle_secret"}, + }, + }, + } +} diff --git a/internal/auditspec/engine.go b/internal/auditspec/engine.go new file mode 100644 index 00000000..6bf583d0 --- /dev/null +++ b/internal/auditspec/engine.go @@ -0,0 +1,484 @@ +package auditspec + +import ( + "fmt" + "sort" + "strings" + + "github.com/cssbruno/gowdk/internal/diagnostics" + "github.com/cssbruno/gowdk/internal/securitymanifest" +) + +// endpointKindForSelector maps selector shorthands to manifest endpoint kinds. +var endpointKindForSelector = map[string]string{ + "act": "action", + "action": "action", + "api": "api", + "fragment": "fragment", + "command": "command", + "query": "query", +} + +// Evaluate matches policies against the posture manifest and returns findings. +// It first reports policy-resolution problems (cycles, unknown extends), then +// the per-target rule violations. Findings are returned in a stable order. +func Evaluate(manifest securitymanifest.SecurityManifest, policies []Policy) []Finding { + resolved, resolutionFindings := resolve(policies) + findings := append([]Finding(nil), resolutionFindings...) + + matchedAnything := map[string]bool{} + + for _, endpoint := range manifest.Endpoints { + for _, policy := range resolved { + if !policy.matchesEndpoint(endpoint) { + continue + } + matchedAnything[policy.Name] = true + findings = append(findings, evalEndpoint(endpoint, policy)...) + } + } + + for _, route := range manifest.Routes { + for _, policy := range resolved { + if !policy.matchesRoute(route) { + continue + } + matchedAnything[policy.Name] = true + findings = append(findings, evalRoute(route, policy)...) + } + } + + for _, policy := range resolved { + if !policy.hasFrontendSelector() { + continue + } + matchedAnything[policy.Name] = true + findings = append(findings, evalFrontend(manifest.Frontend, policy)...) + } + + findings = append(findings, unmatchedSelectorFindings(resolved, matchedAnything)...) + return findings +} + +// resolve expands extends so each policy carries its full rule set, and reports +// cycles, unknown parents, duplicate names, and unknown selectors. +func resolve(policies []Policy) ([]Policy, []Finding) { + var findings []Finding + byName := map[string]Policy{} + for _, policy := range policies { + if _, exists := byName[policy.Name]; exists { + findings = append(findings, Finding{ + Code: "policy_duplicate_name", + Severity: severityFor("policy_duplicate_name"), + Policy: policy.Name, + Source: policy.Source, + Message: fmt.Sprintf("policy %q is declared more than once", policy.Name), + }) + continue + } + byName[policy.Name] = policy + } + + for _, policy := range policies { + for _, selector := range policy.Selectors { + if selector.Kind == SelectorUnknown { + findings = append(findings, Finding{ + Code: "policy_unknown_selector", + Severity: severityFor("policy_unknown_selector"), + Policy: policy.Name, + Source: policy.Source, + Message: fmt.Sprintf("policy %q uses an unrecognized selector %q", policy.Name, selector.Raw), + }) + } + } + } + + resolved := make([]Policy, 0, len(policies)) + for _, policy := range policies { + rules, ok := flattenRules(policy.Name, byName, map[string]bool{}, &findings) + if !ok { + continue + } + policy.Rules = rules + resolved = append(resolved, policy) + } + return resolved, findings +} + +// flattenRules returns the rules of name plus all transitively extended rules. +// Parent rules come first so a child can override them by appearing later. +func flattenRules(name string, byName map[string]Policy, visiting map[string]bool, findings *[]Finding) ([]Rule, bool) { + policy, ok := byName[name] + if !ok { + return nil, false + } + if visiting[name] { + *findings = append(*findings, Finding{ + Code: "policy_extends_cycle", + Severity: severityFor("policy_extends_cycle"), + Policy: name, + Source: policy.Source, + Message: fmt.Sprintf("policy %q forms an extends cycle", name), + }) + return nil, false + } + visiting[name] = true + defer delete(visiting, name) + + var rules []Rule + for _, parent := range policy.Extends { + parentRules, ok := flattenRules(parent, byName, visiting, findings) + if !ok { + if _, exists := byName[parent]; !exists { + *findings = append(*findings, Finding{ + Code: "policy_unknown_extends", + Severity: severityFor("policy_unknown_extends"), + Policy: policy.Name, + Source: policy.Source, + Message: fmt.Sprintf("policy %q extends undefined policy %q", policy.Name, parent), + }) + } + return nil, false + } + rules = append(rules, parentRules...) + } + rules = append(rules, policy.Rules...) + return rules, true +} + +func unmatchedSelectorFindings(policies []Policy, matched map[string]bool) []Finding { + var findings []Finding + seen := map[string]bool{} + for _, policy := range policies { + if policy.Builtin || matched[policy.Name] || seen[policy.Name] { + continue + } + if len(policy.Selectors) == 0 { + continue + } + seen[policy.Name] = true + findings = append(findings, Finding{ + Code: "policy_selector_matched_nothing", + Severity: severityFor("policy_selector_matched_nothing"), + Policy: policy.Name, + Source: policy.Source, + Message: fmt.Sprintf("policy %q matched no routes or endpoints", policy.Name), + }) + } + return findings +} + +func (policy Policy) matchesEndpoint(endpoint securitymanifest.EndpointEntry) bool { + for _, selector := range policy.Selectors { + switch selector.Kind { + case SelectorEndpoint: + kind, glob, ok := splitEndpointSelector(selector.Raw) + if !ok || kind != endpoint.Kind { + continue + } + if matchGlob(glob, endpoint.ID) || matchGlob(glob, endpoint.Path) { + return true + } + case SelectorRoute: + if matchRouteGlob(selector.Raw, endpoint.Path) { + return true + } + } + } + return false +} + +func (policy Policy) matchesRoute(route securitymanifest.RouteEntry) bool { + for _, selector := range policy.Selectors { + if selector.Kind == SelectorRoute && matchRouteGlob(selector.Raw, route.Route) { + return true + } + } + return false +} + +func (policy Policy) hasFrontendSelector() bool { + for _, selector := range policy.Selectors { + if selector.Kind == SelectorFrontend { + return true + } + } + return false +} + +func evalEndpoint(endpoint securitymanifest.EndpointEntry, policy Policy) []Finding { + var findings []Finding + for _, rule := range policy.Rules { + switch rule.Kind { + case RuleRequireCSRF: + if !endpoint.CSRF { + findings = append(findings, finding(rule, policy, endpointTarget(endpoint), endpoint.Source, + fmt.Sprintf("%s endpoint %s does not enforce CSRF", endpoint.Kind, endpoint.ID), + "Enable Build.CSRF.Enabled, or waive the rule with a documented reason.")) + } + case RuleRequireAnyGuard: + if endpoint.DefaultDeny { + findings = append(findings, finding(rule, policy, endpointTarget(endpoint), endpoint.Source, + fmt.Sprintf("%s endpoint %s declares no guard and is denied by omission", endpoint.Kind, endpoint.ID), + "Add a guard to the page that declares this endpoint.")) + } + case RuleRequireGuard: + if !containsGuard(endpoint.Guards, rule.Value) { + findings = append(findings, finding(rule, policy, endpointTarget(endpoint), endpoint.Source, + fmt.Sprintf("%s endpoint %s does not declare required guard %q", endpoint.Kind, endpoint.ID, rule.Value), + fmt.Sprintf("Add guard %s to the page that declares this endpoint.", rule.Value))) + } + case RuleDenyPublic: + if endpoint.Public { + findings = append(findings, finding(rule, policy, endpointTarget(endpoint), endpoint.Source, + fmt.Sprintf("%s endpoint %s is public but policy denies public access", endpoint.Kind, endpoint.ID), + "Replace guard public with a protective guard, or narrow the policy selector.")) + } + case RuleMaxBody: + limit, ok := parseSize(rule.Value) + if ok && endpoint.BodyLimitBytes > limit { + findings = append(findings, finding(rule, policy, endpointTarget(endpoint), endpoint.Source, + fmt.Sprintf("%s endpoint %s body limit %d exceeds policy maximum %d", endpoint.Kind, endpoint.ID, endpoint.BodyLimitBytes, limit), + "Lower Build.BodyLimits, or raise the policy max_body if intentional.")) + } + } + } + return findings +} + +func evalRoute(route securitymanifest.RouteEntry, policy Policy) []Finding { + var findings []Finding + for _, rule := range policy.Rules { + switch rule.Kind { + case RuleRequireGuard: + if !containsGuard(route.Guards, rule.Value) { + findings = append(findings, finding(rule, policy, routeTarget(route), route.Source, + fmt.Sprintf("route %s does not declare required guard %q", route.Route, rule.Value), + fmt.Sprintf("Add guard %s to %s.", rule.Value, route.PageID))) + } + case RuleRequireAnyGuard: + if route.DefaultDeny { + findings = append(findings, finding(rule, policy, routeTarget(route), route.Source, + fmt.Sprintf("route %s declares no guard and is denied by omission", route.Route), + fmt.Sprintf("State access on %s with guard public or a protective guard.", route.PageID))) + } + case RuleDenyPublic: + if route.Public { + findings = append(findings, finding(rule, policy, routeTarget(route), route.Source, + fmt.Sprintf("route %s is public but policy denies public access", route.Route), + "Replace guard public with a protective guard, or narrow the policy selector.")) + } + } + } + return findings +} + +func evalFrontend(surface securitymanifest.FrontendSurface, policy Policy) []Finding { + var findings []Finding + for _, rule := range policy.Rules { + switch rule.Kind { + case RuleNoSecretsInBundle: + for _, leak := range surface.BundleSecrets { + findings = append(findings, finding(rule, policy, "frontend", leak.Source, + fmt.Sprintf("embedded output carries a secret-shaped value (%s)", leak.Kind), + "Move the secret to a runtime environment variable, or exclude the file from embedded output.")) + } + case RuleRequireHeader: + if !containsGuard(surface.ConfiguredHeaders, rule.Value) { + findings = append(findings, finding(rule, policy, "frontend", "", + fmt.Sprintf("generated app does not declare required response header %q", rule.Value), + "Enable Build.SecurityHeaders and configure the header.")) + } + case RuleAllowRawHTML: + // Handled by evalRawHTMLSinks against the full allowlist below. + } + } + findings = append(findings, evalRawHTMLSinks(surface, policy)...) + return findings +} + +// evalRawHTMLSinks reports raw-HTML sinks that are not allowlisted by any +// RuleAllowRawHTML rule on the matched frontend policy. +func evalRawHTMLSinks(surface securitymanifest.FrontendSurface, policy Policy) []Finding { + if len(surface.RawHTMLSinks) == 0 { + return nil + } + allow := map[string]bool{} + guards := false + for _, rule := range policy.Rules { + if rule.Kind == RuleAllowRawHTML { + guards = true + allow[rule.Value] = true + } + } + if !guards { + return nil + } + var findings []Finding + rule := Rule{Kind: RuleAllowRawHTML, Code: "audit_raw_html_sink"} + for _, sink := range surface.RawHTMLSinks { + key := sink.Source + if allow[key] || allow[sink.OwnerID+":"+sink.Field] { + continue + } + findings = append(findings, finding(rule, policy, "frontend", sink.Source, + fmt.Sprintf("raw HTML sink %s:%s is not allowlisted", sink.OwnerID, sink.Field), + "Render escaped output, or add the sink to the policy raw-HTML allowlist.")) + } + return findings +} + +func finding(rule Rule, policy Policy, target, source, message, remediation string) Finding { + return Finding{ + Code: rule.Code, + Severity: severityFor(rule.Code), + Target: target, + Policy: policy.Name, + Rule: string(rule.Kind), + Message: message, + Source: source, + Remediation: remediation, + } +} + +func endpointTarget(endpoint securitymanifest.EndpointEntry) string { + return endpoint.Kind + ":" + endpoint.ID +} + +func routeTarget(route securitymanifest.RouteEntry) string { + return "route:" + route.Route +} + +func containsGuard(guards []string, want string) bool { + for _, guard := range guards { + if guard == want { + return true + } + } + return false +} + +// ParseSelector classifies a raw selector string. +func ParseSelector(raw string) Selector { + raw = strings.TrimSpace(raw) + switch { + case raw == "frontend": + return Selector{Raw: raw, Kind: SelectorFrontend} + case strings.HasPrefix(raw, "/"): + return Selector{Raw: raw, Kind: SelectorRoute} + default: + if _, _, ok := splitEndpointSelector(raw); ok { + return Selector{Raw: raw, Kind: SelectorEndpoint} + } + return Selector{Raw: raw, Kind: SelectorUnknown} + } +} + +func splitEndpointSelector(raw string) (kind, glob string, ok bool) { + colon := strings.IndexByte(raw, ':') + if colon <= 0 { + return "", "", false + } + prefix := raw[:colon] + mapped, known := endpointKindForSelector[prefix] + if !known { + return "", "", false + } + glob = raw[colon+1:] + if glob == "" { + glob = "*" + } + return mapped, glob, true +} + +// matchGlob matches a simple glob (supporting a trailing or standalone *) +// against a single token. +func matchGlob(pattern, value string) bool { + if pattern == "*" || pattern == "" { + return true + } + if strings.HasSuffix(pattern, "*") { + return strings.HasPrefix(value, strings.TrimSuffix(pattern, "*")) + } + return pattern == value +} + +// matchRouteGlob matches a route glob against a path. ** matches zero or more +// trailing segments; * matches exactly one segment; other segments match +// literally. +func matchRouteGlob(pattern, path string) bool { + patternSegments := strings.Split(strings.Trim(pattern, "/"), "/") + pathSegments := strings.Split(strings.Trim(path, "/"), "/") + return matchSegments(patternSegments, pathSegments) +} + +func matchSegments(pattern, path []string) bool { + for index, segment := range pattern { + if segment == "**" { + return true + } + if index >= len(path) { + return false + } + if segment == "*" { + continue + } + if segment != path[index] { + return false + } + } + return len(pattern) == len(path) +} + +// parseSize parses a byte size such as "256kb", "1mb", or a bare byte count. +func parseSize(value string) (int64, bool) { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return 0, false + } + multiplier := int64(1) + switch { + case strings.HasSuffix(value, "kb"): + multiplier, value = 1<<10, strings.TrimSuffix(value, "kb") + case strings.HasSuffix(value, "mb"): + multiplier, value = 1<<20, strings.TrimSuffix(value, "mb") + case strings.HasSuffix(value, "gb"): + multiplier, value = 1<<30, strings.TrimSuffix(value, "gb") + case strings.HasSuffix(value, "b"): + value = strings.TrimSuffix(value, "b") + } + value = strings.TrimSpace(value) + var number int64 + for _, char := range value { + if char < '0' || char > '9' { + return 0, false + } + number = number*10 + int64(char-'0') + } + return number * multiplier, true +} + +// SortFindings orders findings deterministically by severity, code, then target. +func SortFindings(findings []Finding) { + sort.SliceStable(findings, func(i, j int) bool { + left, right := findings[i], findings[j] + if severityRank(left.Severity) != severityRank(right.Severity) { + return severityRank(left.Severity) < severityRank(right.Severity) + } + if left.Code != right.Code { + return left.Code < right.Code + } + return left.Target < right.Target + }) +} + +func severityRank(severity diagnostics.Severity) int { + switch severity { + case diagnostics.SeverityError: + return 0 + case diagnostics.SeverityWarning: + return 1 + default: + return 2 + } +} diff --git a/internal/auditspec/engine_test.go b/internal/auditspec/engine_test.go new file mode 100644 index 00000000..4e980c3c --- /dev/null +++ b/internal/auditspec/engine_test.go @@ -0,0 +1,193 @@ +package auditspec + +import ( + "testing" + + "github.com/cssbruno/gowdk/internal/diagnostics" + "github.com/cssbruno/gowdk/internal/securitymanifest" +) + +func codes(findings []Finding) map[string]int { + counts := map[string]int{} + for _, finding := range findings { + counts[finding.Code]++ + } + return counts +} + +func TestBaselineFlagsMissingCSRFAndPublicAPI(t *testing.T) { + manifest := securitymanifest.SecurityManifest{ + Endpoints: []securitymanifest.EndpointEntry{ + {ID: "Submit", Kind: "action", Method: "POST", Path: "/signup", Guards: []string{"public"}, CSRF: false, Public: true}, + {ID: "Health", Kind: "api", Method: "GET", Path: "/api/health", CSRF: false, DefaultDeny: true}, + {ID: "Refresh", Kind: "fragment", Method: "GET", Path: "/frag", DefaultDeny: true}, + }, + Frontend: securitymanifest.FrontendSurface{}, + } + + got := codes(Evaluate(manifest, Baseline())) + if got["audit_action_missing_csrf"] != 1 { + t.Fatalf("expected one missing-CSRF finding, got %d", got["audit_action_missing_csrf"]) + } + if got["audit_api_public_by_omission"] != 1 { + t.Fatalf("expected one public-by-omission API finding, got %d", got["audit_api_public_by_omission"]) + } + if got["audit_guardless_endpoint_page"] != 1 { + t.Fatalf("expected one guardless fragment finding, got %d", got["audit_guardless_endpoint_page"]) + } +} + +func TestBaselinePassesWhenPostureIsSound(t *testing.T) { + manifest := securitymanifest.SecurityManifest{ + Endpoints: []securitymanifest.EndpointEntry{ + {ID: "Submit", Kind: "action", Method: "POST", Path: "/signup", Guards: []string{"auth.required"}, CSRF: true}, + {ID: "List", Kind: "api", Method: "GET", Path: "/api/list", Guards: []string{"permission:list.read"}}, + }, + } + findings := Evaluate(manifest, Baseline()) + if len(findings) != 0 { + t.Fatalf("expected no findings for a sound posture, got %#v", findings) + } +} + +func TestSeverityComesFromRegistry(t *testing.T) { + manifest := securitymanifest.SecurityManifest{ + Endpoints: []securitymanifest.EndpointEntry{ + {ID: "Submit", Kind: "action", Method: "POST", Path: "/signup", Guards: []string{"public"}, CSRF: false, Public: true}, + }, + } + findings := Evaluate(manifest, Baseline()) + if len(findings) == 0 { + t.Fatal("expected a finding") + } + if findings[0].Severity != diagnostics.SeverityError { + t.Fatalf("severity must resolve from the registry: got %q", findings[0].Severity) + } +} + +func TestDeclaredPolicyExtendsComposes(t *testing.T) { + manifest := securitymanifest.SecurityManifest{ + Routes: []securitymanifest.RouteEntry{ + {PageID: "admin", Route: "/admin/patients", Kind: "ssr", Guards: []string{"auth.required"}}, + }, + } + policies := []Policy{ + {Name: "authed", Source: "a.audit.gwdk", Selectors: []Selector{{Raw: "/admin/**", Kind: SelectorRoute}}, Rules: []Rule{{Kind: RuleDenyPublic, Code: "audit_public_not_allowed"}}}, + {Name: "admin", Source: "a.audit.gwdk", Extends: []string{"authed"}, Selectors: []Selector{{Raw: "/admin/**", Kind: SelectorRoute}}, Rules: []Rule{{Kind: RuleRequireGuard, Value: "role:admin", Code: "audit_required_guard_missing"}}}, + } + got := codes(Evaluate(manifest, policies)) + // "admin" inherits deny_public from "authed" (route is not public, so no + // finding there) and adds require role:admin (route lacks it -> finding). + if got["audit_required_guard_missing"] != 1 { + t.Fatalf("expected one required-guard finding from composed policy, got %#v", got) + } +} + +func TestExtendsCycleIsReported(t *testing.T) { + policies := []Policy{ + {Name: "a", Extends: []string{"b"}, Source: "x"}, + {Name: "b", Extends: []string{"a"}, Source: "x"}, + } + got := codes(Evaluate(securitymanifest.SecurityManifest{}, policies)) + if got["policy_extends_cycle"] == 0 { + t.Fatalf("expected a cycle finding, got %#v", got) + } +} + +func TestUnknownExtendsIsReported(t *testing.T) { + policies := []Policy{ + {Name: "a", Extends: []string{"missing"}, Source: "x"}, + } + got := codes(Evaluate(securitymanifest.SecurityManifest{}, policies)) + if got["policy_unknown_extends"] == 0 { + t.Fatalf("expected an unknown-extends finding, got %#v", got) + } +} + +func TestDuplicatePolicyNameIsReported(t *testing.T) { + policies := []Policy{ + {Name: "a", Source: "x"}, + {Name: "a", Source: "y"}, + } + got := codes(Evaluate(securitymanifest.SecurityManifest{}, policies)) + if got["policy_duplicate_name"] == 0 { + t.Fatalf("expected a duplicate-name finding, got %#v", got) + } +} + +func TestMaxBodyRuleFlagsOversizedLimit(t *testing.T) { + manifest := securitymanifest.SecurityManifest{ + Endpoints: []securitymanifest.EndpointEntry{ + {ID: "Upload", Kind: "action", Method: "POST", Path: "/upload", Guards: []string{"auth.required"}, CSRF: true, BodyLimitBytes: 1 << 20}, + }, + } + policies := []Policy{ + {Name: "tight", Source: "x", Selectors: []Selector{{Raw: "act:*", Kind: SelectorEndpoint}}, Rules: []Rule{{Kind: RuleMaxBody, Value: "256kb", Code: "audit_max_body_exceeds_policy"}}}, + } + got := codes(Evaluate(manifest, policies)) + if got["audit_max_body_exceeds_policy"] != 1 { + t.Fatalf("expected one oversized-body finding, got %#v", got) + } +} + +func TestRouteGlobMatching(t *testing.T) { + cases := []struct { + pattern string + path string + want bool + }{ + {"/admin/**", "/admin/patients", true}, + {"/admin/**", "/admin", true}, + {"/admin/**", "/admin/a/b/c", true}, + {"/admin/**", "/public", false}, + {"/blog/*", "/blog/post", true}, + {"/blog/*", "/blog/post/comments", false}, + {"/", "/", true}, + {"/dashboard", "/dashboard", true}, + {"/dashboard", "/dash", false}, + } + for _, tc := range cases { + if got := matchRouteGlob(tc.pattern, tc.path); got != tc.want { + t.Errorf("matchRouteGlob(%q, %q) = %v, want %v", tc.pattern, tc.path, got, tc.want) + } + } +} + +func TestParseSize(t *testing.T) { + cases := []struct { + in string + want int64 + ok bool + }{ + {"256kb", 256 * 1024, true}, + {"1mb", 1 << 20, true}, + {"512", 512, true}, + {"2gb", 2 << 30, true}, + {"", 0, false}, + {"abc", 0, false}, + } + for _, tc := range cases { + got, ok := parseSize(tc.in) + if ok != tc.ok || (ok && got != tc.want) { + t.Errorf("parseSize(%q) = (%d, %v), want (%d, %v)", tc.in, got, ok, tc.want, tc.ok) + } + } +} + +func TestParseSelectorClassifies(t *testing.T) { + cases := []struct { + raw string + want SelectorKind + }{ + {"/admin/**", SelectorRoute}, + {"act:*", SelectorEndpoint}, + {"api:Health", SelectorEndpoint}, + {"frontend", SelectorFrontend}, + {"nonsense", SelectorUnknown}, + } + for _, tc := range cases { + if got := ParseSelector(tc.raw).Kind; got != tc.want { + t.Errorf("ParseSelector(%q).Kind = %q, want %q", tc.raw, got, tc.want) + } + } +} diff --git a/internal/buildgen/build.go b/internal/buildgen/build.go index 83d6a7be..f53d5179 100644 --- a/internal/buildgen/build.go +++ b/internal/buildgen/build.go @@ -131,6 +131,12 @@ func buildFromIR(config gowdk.Config, ir gwdkir.Program, backendBindings []sourc } result.OpenAPIPath = openAPIPath reporter.info("report", "openapi_written", "OpenAPI report written", BuildEvent{Path: eventPath(outputDir, openAPIPath)}) + securityManifestPath, err := writeSecurityManifest(outputDir, config, ir) + if err != nil { + return Result{}, reporter.fail("manifest", err) + } + result.SecurityManifestPath = securityManifestPath + reporter.info("manifest", "security_manifest_written", "security manifest written", BuildEvent{Path: eventPath(outputDir, securityManifestPath)}) reporter.info("complete", "build_complete", "SPA build completed", BuildEvent{ Data: map[string]string{ "pages": fmt.Sprint(len(result.Artifacts)), @@ -269,6 +275,13 @@ func buildMemoryFromIR(config gowdk.Config, ir gwdkir.Program, backendBindings [ } result.Files[openAPIFile] = openAPI reporter.info("report", "openapi_collected", "OpenAPI report collected", BuildEvent{Path: openAPIFile}) + securityManifest, err := securityManifestPayload(config, ir) + if err != nil { + return MemoryResult{}, reporter.fail("manifest", err) + } + result.Files[securityManifestFile] = securityManifest + result.SecurityManifestPath = filepath.Join(outputDir, securityManifestFile) + reporter.info("manifest", "security_manifest_collected", "security manifest collected", BuildEvent{Path: securityManifestFile}) reporter.info("complete", "build_complete", "in-memory SPA build completed", BuildEvent{ Data: map[string]string{ "pages": fmt.Sprint(len(result.Artifacts)), @@ -561,6 +574,12 @@ func buildIncrementalFromIR(config gowdk.Config, ir gwdkir.Program, outputDir st } result.OpenAPIPath = openAPIPath reporter.info("report", "openapi_written", "OpenAPI report written", BuildEvent{Path: eventPath(outputDir, openAPIPath)}) + securityManifestPath, err := writeSecurityManifest(outputDir, config, ir) + if err != nil { + return Result{}, reporter.fail("manifest", err) + } + result.SecurityManifestPath = securityManifestPath + reporter.info("manifest", "security_manifest_written", "security manifest written", BuildEvent{Path: eventPath(outputDir, securityManifestPath)}) reporter.info("complete", "build_complete", "incremental SPA build completed", BuildEvent{ Data: map[string]string{ "pages": fmt.Sprint(len(result.Artifacts)), diff --git a/internal/buildgen/buildgen.go b/internal/buildgen/buildgen.go index 4d9b74eb..fd9be768 100644 --- a/internal/buildgen/buildgen.go +++ b/internal/buildgen/buildgen.go @@ -13,6 +13,8 @@ const assetManifestFile = "gowdk-assets.json" const buildReportFile = "gowdk-build-report.json" +const securityManifestFile = "gowdk-security.json" + const immutableAssetCachePolicy = "public, max-age=31536000, immutable" // noCacheAssetCachePolicy forces revalidation (via ETag/ModTime) for assets diff --git a/internal/buildgen/security_manifest.go b/internal/buildgen/security_manifest.go new file mode 100644 index 00000000..ec904f6d --- /dev/null +++ b/internal/buildgen/security_manifest.go @@ -0,0 +1,31 @@ +package buildgen + +import ( + "encoding/json" + "path/filepath" + + "github.com/cssbruno/gowdk" + "github.com/cssbruno/gowdk/internal/gwdkir" + "github.com/cssbruno/gowdk/internal/securitymanifest" +) + +// securityManifestPayload renders the IR-derived security posture +// (gowdk-security.json). It is pure data — declarative posture, no policy +// evaluation — so the artifact is stable and auditable on its own. Policy +// findings live in gowdk audit, which reads this same posture. +func securityManifestPayload(config gowdk.Config, ir gwdkir.Program) ([]byte, error) { + manifest := securitymanifest.Build(config, ir) + return json.MarshalIndent(manifest, "", " ") +} + +func writeSecurityManifest(outputDir string, config gowdk.Config, ir gwdkir.Program) (string, error) { + payload, err := securityManifestPayload(config, ir) + if err != nil { + return "", err + } + manifestPath := filepath.Join(outputDir, securityManifestFile) + if err := writeFileIfChanged(manifestPath, payload); err != nil { + return "", err + } + return manifestPath, nil +} diff --git a/internal/buildgen/types.go b/internal/buildgen/types.go index 70785bab..3fbb2010 100644 --- a/internal/buildgen/types.go +++ b/internal/buildgen/types.go @@ -23,15 +23,16 @@ type AssetArtifact struct { } type Result struct { - Artifacts []Artifact - CSSArtifacts []CSSArtifact - AssetArtifacts []AssetArtifact - RouteManifestPath string - AssetManifestPath string - OpenAPIPath string - BuildReportPath string - Report BuildReport - WriteStats WriteStats + Artifacts []Artifact + CSSArtifacts []CSSArtifact + AssetArtifacts []AssetArtifact + RouteManifestPath string + AssetManifestPath string + OpenAPIPath string + SecurityManifestPath string + BuildReportPath string + Report BuildReport + WriteStats WriteStats } type WriteStats struct { diff --git a/internal/contractscan/ast_helpers.go b/internal/contractscan/ast_helpers.go index c2aee767..c663f806 100644 --- a/internal/contractscan/ast_helpers.go +++ b/internal/contractscan/ast_helpers.go @@ -237,7 +237,10 @@ func exprString(fset *token.FileSet, expr ast.Expr) string { func shouldSkipDir(name string) bool { switch name { - case ".git", ".gowdk", "vendor", "node_modules", "dist", "bin": + case ".git", ".gowdk", ".claude", "vendor", "node_modules", "dist", "bin": + // .claude holds tooling state and nested git worktrees + // (.claude/worktrees/*); scanning them double-counts a sibling + // checkout's contract registrations as duplicates of this tree's. return true default: return false diff --git a/internal/diagnostics/explain.go b/internal/diagnostics/explain.go index b19b12c1..071f6ba5 100644 --- a/internal/diagnostics/explain.go +++ b/internal/diagnostics/explain.go @@ -161,6 +161,128 @@ guard public "Deferred behavior (transitions, document targets, DOM actions) belongs to CSS, page metadata, or future addon contracts.", }, }, + "audit_action_missing_csrf": { + Details: "gowdk audit derives an action endpoint that decodes a request body without CSRF enforcement. The built-in security baseline (and any require csrf policy) treats this as an error because action POSTs are cross-site-forgeable.", + NextSteps: []string{ + "Set Build.CSRF.Enabled and a runtime CSRF secret so generated actions validate tokens before decoding.", + "Use an audit policy waiver with a documented reason if the endpoint is intentionally exempt.", + }, + }, + "audit_api_public_by_omission": { + Details: "An API endpoint inherits no protective guard, so it would be callable without authorization. The baseline forbids public-by-omission APIs; access must be stated, not granted by omission.", + NextSteps: []string{ + "Add a guard such as guard permission:resource.read to the page that declares the API endpoint.", + "Add guard public only when the API is intentionally unauthenticated, and confirm the policy allows it.", + }, + }, + "audit_bundle_secret": { + Details: "The embedded build output or literal build-time data contains a value that matches a secret-shaped pattern (for example an env file, a private key, or a token). Secrets must not ship inside generated artifacts.", + NextSteps: []string{ + "Move the secret to a runtime environment variable and read it in Go, not at build time.", + "Exclude the offending file from the embedded asset set.", + }, + }, + "audit_client_route_unguarded": { + Details: "A client or SPA route is not covered by the generated default-deny registry, so static hosting could serve it without the 403 gate. The static-export caveat in docs/language/guards.md applies.", + NextSteps: []string{ + "State the route's access with guard public or a protective guard so it joins the deny registry.", + "Serve the route through the generated Go server, which enforces the deny registry.", + }, + }, + "audit_guardless_endpoint_page": { + Details: "A page that declares act, api, or fragment endpoints has no guard. Those endpoints would be publicly callable even though the page GET route is denied, which contradicts default-deny.", + NextSteps: []string{ + "Add a guard to the page so its derived endpoints inherit it.", + "Use guard public only when every derived endpoint is intentionally unauthenticated.", + }, + }, + "audit_headers_missing": { + Details: "An audit policy requires a security response header (for example Content-Security-Policy) but the generated app is not configured to emit it. This is a static-posture warning; runtime verification is a separate error.", + NextSteps: []string{ + "Enable Build.SecurityHeaders and configure the required header in gowdk.config.go.", + "Remove the require header rule if the header is owned by an upstream proxy.", + }, + }, + "audit_headers_runtime_missing": { + Details: "gowdk audit --run started the generated app and a required security response header was absent from a served response. The runtime contradicts the declared posture.", + NextSteps: []string{ + "Confirm Build.SecurityHeaders emits the header on the served route and fragment responses.", + "Check for middleware or a reverse proxy stripping the header in the run environment.", + }, + }, + "audit_max_body_exceeds_policy": { + Details: "An endpoint's configured request body limit is larger than the maximum a policy allows. Oversized limits widen the denial-of-service surface.", + NextSteps: []string{ + "Lower Build.BodyLimits (or the per-target limit) to the policy maximum.", + "Raise the policy max_body rule if the larger limit is intentional and justified.", + }, + }, + "audit_public_not_allowed": { + Details: "A target route or endpoint is public (guard public or no protective guard) but a policy deny public rule forbids public access for its selector.", + NextSteps: []string{ + "Add a protective guard to the target so it is no longer public.", + "Narrow the policy selector or add a waiver if the target is intentionally public.", + }, + }, + "audit_raw_html_sink": { + Details: "A view renders raw, unescaped HTML through g:html (or an equivalent raw sink). Raw sinks are an XSS surface and must be explicitly allowlisted so each one is a reviewed decision.", + NextSteps: []string{ + "Render escaped interpolation instead of g:html when raw HTML is not required.", + "Add the sink (source:field) to the policy raw-HTML allowlist when raw output is intentional and the input is trusted.", + }, + }, + "audit_required_guard_missing": { + Details: "A policy requires a role or permission guard (for example require role:admin) on the selected target, but the target does not declare it. The static posture does not satisfy the declared permission.", + NextSteps: []string{ + "Add the required guard ID to the matched page so its routes and endpoints inherit it.", + "Adjust the policy selector or required guard if the rule is too broad.", + }, + }, + "audit_runtime_mismatch": { + Details: "gowdk audit --run observed runtime behavior that contradicts the declared static posture (for example a route that should be denied returned a success status).", + NextSteps: []string{ + "Reconcile the generated handler behavior with the declared guard, CSRF, or limit metadata.", + "File the mismatch with the route or endpoint ID reported by the finding.", + }, + }, + "audit_test_failed": { + Details: "An audit integration test expectation declared in a *.audit.gwdk file did not hold when run against the generated app.", + NextSteps: []string{ + "Inspect the reported request, expected value, and actual value to locate the divergence.", + "Fix the handler, the guard configuration, or the test expectation as appropriate.", + }, + }, + "policy_duplicate_name": { + Details: "Two audit policies declare the same name. Policy names must be unique so extends and override resolution is unambiguous.", + NextSteps: []string{ + "Rename one of the conflicting policies.", + }, + }, + "policy_extends_cycle": { + Details: "An audit policy extends chain forms a cycle (for example A extends B and B extends A), so composition cannot be resolved.", + NextSteps: []string{ + "Break the cycle so the extends graph is acyclic.", + }, + }, + "policy_selector_matched_nothing": { + Details: "An audit policy selector matched no routes or endpoints. The rule has no effect, which often signals a typo or a stale selector.", + NextSteps: []string{ + "Check the selector glob or kind against gowdk routes and gowdk endpoints output.", + "Remove the policy or rule if the target no longer exists.", + }, + }, + "policy_unknown_extends": { + Details: "An audit policy extends a policy name that is not defined in any loaded *.audit.gwdk file or the built-in baseline.", + NextSteps: []string{ + "Define the referenced policy, or correct the extends name.", + }, + }, + "policy_unknown_selector": { + Details: "An audit policy uses a selector form gowdk audit does not recognize. Supported forms are route globs (for example /admin/**) and kind selectors (act:*, api:*, fragment:*, and contract kinds).", + NextSteps: []string{ + "Use a supported selector form from docs/language/audit.md.", + }, + }, "public_guard_exclusive": { Details: "The public guard means no protected guard should run for that page, so it cannot be mixed with other guard IDs.", NextSteps: []string{ diff --git a/internal/diagnostics/registry.go b/internal/diagnostics/registry.go index ed551822..7edcb357 100644 --- a/internal/diagnostics/registry.go +++ b/internal/diagnostics/registry.go @@ -60,6 +60,19 @@ var Registry = []Code{ {Code: "addon_go_block_diagnostic", Area: "go-block", Stability: StabilityAddon, Severity: SeverityError, Summary: "addon-provided go block diagnostic without a custom code"}, {Code: "ambiguous_backend_handler", Area: "backend", Stability: StabilityStable, Severity: SeverityWarning, Summary: "a backend handler is declared in both same-package Go and an inline go block"}, {Code: "ambiguous_dynamic_route", Area: "routing", Stability: StabilityStable, Severity: SeverityError, Summary: "dynamic page route overlaps another dynamic page route"}, + {Code: "audit_action_missing_csrf", Area: "audit", Stability: StabilityExperimental, Severity: SeverityError, Summary: "action endpoint does not enforce CSRF as required by the security baseline or policy"}, + {Code: "audit_api_public_by_omission", Area: "audit", Stability: StabilityExperimental, Severity: SeverityError, Summary: "API endpoint inherits no protective guard and policy forbids public-by-omission APIs"}, + {Code: "audit_bundle_secret", Area: "audit", Stability: StabilityExperimental, Severity: SeverityError, Summary: "embedded build output or build-time data carries a secret-shaped value"}, + {Code: "audit_client_route_unguarded", Area: "audit", Stability: StabilityExperimental, Severity: SeverityError, Summary: "a client or SPA route is not covered by the generated default-deny registry"}, + {Code: "audit_guardless_endpoint_page", Area: "audit", Stability: StabilityExperimental, Severity: SeverityError, Summary: "a page exposing act, api, or fragment endpoints declares no guard"}, + {Code: "audit_headers_missing", Area: "audit", Stability: StabilityExperimental, Severity: SeverityWarning, Summary: "generated app does not declare a security response header required by policy"}, + {Code: "audit_headers_runtime_missing", Area: "audit", Stability: StabilityExperimental, Severity: SeverityError, Summary: "running app did not emit a security response header required by policy"}, + {Code: "audit_max_body_exceeds_policy", Area: "audit", Stability: StabilityExperimental, Severity: SeverityWarning, Summary: "endpoint request body limit is larger than the policy maximum"}, + {Code: "audit_public_not_allowed", Area: "audit", Stability: StabilityExperimental, Severity: SeverityError, Summary: "target is public but policy denies public access"}, + {Code: "audit_raw_html_sink", Area: "audit", Stability: StabilityExperimental, Severity: SeverityWarning, Summary: "view renders raw HTML through g:html without a policy allowlist entry"}, + {Code: "audit_required_guard_missing", Area: "audit", Stability: StabilityExperimental, Severity: SeverityError, Summary: "target does not declare a role or permission guard required by policy"}, + {Code: "audit_runtime_mismatch", Area: "audit", Stability: StabilityExperimental, Severity: SeverityError, Summary: "runtime behavior does not match the declared security posture"}, + {Code: "audit_test_failed", Area: "audit", Stability: StabilityExperimental, Severity: SeverityError, Summary: "an audit integration test expectation did not hold"}, {Code: "backend_binding_required", Area: "backend", Stability: StabilityStable, Severity: SeverityError, Summary: "strict builds require a supported backend handler binding"}, {Code: "client_go_block_wasm_build_error", Area: "wasm", Stability: StabilityExperimental, Severity: SeverityError, Summary: "page go client WASM build failed"}, {Code: "client_go_block_wasm_entrypoint_error", Area: "wasm", Stability: StabilityExperimental, Severity: SeverityError, Summary: "page go client WASM entrypoint is missing or invalid"}, @@ -131,6 +144,11 @@ var Registry = []Code{ {Code: "package_must_be_first", Area: "packages", Stability: StabilityStable, Severity: SeverityError, Summary: "package declaration is not the first non-comment declaration"}, {Code: "page_store_error", Area: "stores", Stability: StabilityExperimental, Severity: SeverityError, Summary: "page store type or initializer is invalid"}, {Code: "parse_error", Area: "parser", Stability: StabilityStable, Severity: SeverityError, Summary: "parser rejected source without a more specific code"}, + {Code: "policy_duplicate_name", Area: "audit", Stability: StabilityExperimental, Severity: SeverityError, Summary: "two audit policies declare the same name"}, + {Code: "policy_extends_cycle", Area: "audit", Stability: StabilityExperimental, Severity: SeverityError, Summary: "audit policy extends chain forms a cycle"}, + {Code: "policy_selector_matched_nothing", Area: "audit", Stability: StabilityExperimental, Severity: SeverityWarning, Summary: "audit policy selector matched no routes or endpoints"}, + {Code: "policy_unknown_extends", Area: "audit", Stability: StabilityExperimental, Severity: SeverityError, Summary: "audit policy extends a policy that is not defined"}, + {Code: "policy_unknown_selector", Area: "audit", Stability: StabilityExperimental, Severity: SeverityWarning, Summary: "audit policy uses an unrecognized selector form"}, {Code: "public_guard_exclusive", Area: "pages", Stability: StabilityStable, Severity: SeverityError, Summary: "guard public must be the only guard on an intentionally public page"}, {Code: "redundant_component_implementation", Area: "components", Stability: StabilityStable, Severity: SeverityError, Summary: "same component has both GOWDK and generated Go implementations"}, {Code: "revalidate_requires_cache", Area: "cache", Stability: StabilityStable, Severity: SeverityError, Summary: "revalidate requires an explicit cache policy"}, diff --git a/internal/diagnostics/registry_test.go b/internal/diagnostics/registry_test.go index 7e9edf1c..6f34e3f2 100644 --- a/internal/diagnostics/registry_test.go +++ b/internal/diagnostics/registry_test.go @@ -79,7 +79,10 @@ func emittedDiagnosticCodes(t *testing.T) map[string]bool { } if entry.IsDir() { switch entry.Name() { - case ".git", "dist", "node_modules": + case ".git", ".claude", "dist", "node_modules": + // Skip tooling state and nested git worktrees (for example + // .claude/worktrees/*) so the completeness scan only sees this + // checkout's source, not a sibling worktree's diagnostic codes. return filepath.SkipDir } return nil diff --git a/internal/securitymanifest/manifest.go b/internal/securitymanifest/manifest.go new file mode 100644 index 00000000..f7aa0e56 --- /dev/null +++ b/internal/securitymanifest/manifest.go @@ -0,0 +1,204 @@ +// Package securitymanifest projects the stable compiler IR into a declarative, +// machine-readable security posture (gowdk-security.json). It records what the +// generated app actually exposes — every route, backend endpoint, and contract +// with its guards, CSRF state, body limit, and source location, plus the +// frontend surface (raw-HTML sinks, secret scan, header configuration, and +// client route-guard coverage). +// +// The manifest is "what is": it never decides whether the posture is acceptable. +// Policy evaluation and findings live in internal/auditspec, which consumes this +// manifest. Keeping the projection free of policy keeps gowdk-security.json +// stable and equally auditable by a human or an LLM regardless of which policies +// are declared. +package securitymanifest + +import ( + "fmt" + + "github.com/cssbruno/gowdk" + "github.com/cssbruno/gowdk/internal/compiler" + "github.com/cssbruno/gowdk/internal/gwdkir" + "github.com/cssbruno/gowdk/internal/source" +) + +// SchemaVersion is the gowdk-security.json schema version. +const SchemaVersion = 1 + +// PublicGuardID is the guard ID that marks an intentionally public target. +const PublicGuardID = "public" + +// SecurityManifest is the declarative posture of one built module. +type SecurityManifest struct { + Version int `json:"version"` + GeneratedFrom string `json:"generatedFrom"` + Routes []RouteEntry `json:"routes,omitempty"` + Endpoints []EndpointEntry `json:"endpoints,omitempty"` + Contracts []ContractEntry `json:"contracts,omitempty"` + Frontend FrontendSurface `json:"frontend"` +} + +// RouteEntry is the posture of one page/file route. +type RouteEntry struct { + PageID string `json:"pageId"` + Route string `json:"route"` + Kind string `json:"kind"` + Method string `json:"method,omitempty"` + Render string `json:"render,omitempty"` + Guards []string `json:"guards,omitempty"` + Public bool `json:"public"` + DefaultDeny bool `json:"defaultDeny"` + Source string `json:"source,omitempty"` +} + +// EndpointEntry is the posture of one backend action/api/fragment/contract +// endpoint. +type EndpointEntry struct { + ID string `json:"id"` + Kind string `json:"kind"` + Method string `json:"method,omitempty"` + Path string `json:"path,omitempty"` + Guards []string `json:"guards,omitempty"` + CSRF bool `json:"csrf"` + BodyLimitBytes int64 `json:"bodyLimitBytes,omitempty"` + Public bool `json:"public"` + DefaultDeny bool `json:"defaultDeny"` + PageID string `json:"pageId,omitempty"` + Source string `json:"source,omitempty"` +} + +// ContractEntry is the posture of one command/query contract reference. +type ContractEntry struct { + Name string `json:"name"` + Kind string `json:"kind"` + Roles []string `json:"roles,omitempty"` + Status string `json:"status,omitempty"` +} + +// FrontendSurface describes the build-time / client-facing security surface. +// Phase 1 populates UnguardedRoutes from route posture; the remaining fields are +// enriched by the frontend audits. +type FrontendSurface struct { + UnguardedRoutes []string `json:"unguardedRoutes"` + BundleSecrets []BundleLeak `json:"bundleSecrets"` + RawHTMLSinks []RawHTMLSink `json:"rawHtmlSinks"` + ConfiguredHeaders []string `json:"configuredHeaders"` +} + +// BundleLeak records a secret-shaped value found in embedded output or +// build-time data. +type BundleLeak struct { + Source string `json:"source"` + Kind string `json:"kind"` +} + +// RawHTMLSink records one raw-HTML (g:html) render site. +type RawHTMLSink struct { + OwnerKind string `json:"ownerKind"` + OwnerID string `json:"ownerId"` + Field string `json:"field"` + Source string `json:"source"` +} + +// Build projects validated IR into a SecurityManifest. It reuses +// compiler.BuildRouteMetadataFromIR so the posture matches the CLI routes and +// endpoints reports exactly. +func Build(config gowdk.Config, ir gwdkir.Program) SecurityManifest { + metadata := compiler.BuildRouteMetadataFromIR(config, ir) + manifest := SecurityManifest{ + Version: SchemaVersion, + GeneratedFrom: "ir", + Frontend: FrontendSurface{ConfiguredHeaders: []string{}}, + } + + var unguarded []string + for _, route := range metadata.Routes { + entry := RouteEntry{ + PageID: route.PageID, + Route: route.Route, + Kind: string(route.Kind), + Method: route.Method, + Render: string(route.Render), + Guards: append([]string(nil), route.Guards...), + Public: hasPublicGuard(route.Guards), + DefaultDeny: len(route.Guards) == 0, + Source: sourceRef(route.Source, route.SourceSpan), + } + manifest.Routes = append(manifest.Routes, entry) + if entry.DefaultDeny { + unguarded = append(unguarded, route.Route) + } + } + + for _, endpoint := range metadata.Endpoints { + manifest.Endpoints = append(manifest.Endpoints, EndpointEntry{ + ID: endpointID(endpoint), + Kind: string(endpoint.Kind), + Method: endpoint.Method, + Path: endpoint.Route, + Guards: append([]string(nil), endpoint.Guards...), + CSRF: endpoint.CSRF, + BodyLimitBytes: bodyLimitFor(config, endpoint.Kind), + Public: hasPublicGuard(endpoint.Guards), + DefaultDeny: len(endpoint.Guards) == 0, + PageID: endpoint.PageID, + Source: sourceRef(endpoint.Source, endpoint.SourceSpan), + }) + if contract := endpoint.Contract; contract.Name != "" { + manifest.Contracts = append(manifest.Contracts, ContractEntry{ + Name: contract.Name, + Kind: string(contract.Kind), + Roles: append([]string(nil), contract.Roles...), + Status: string(contract.Status), + }) + } + } + + manifest.Frontend.UnguardedRoutes = unguarded + if manifest.Frontend.UnguardedRoutes == nil { + manifest.Frontend.UnguardedRoutes = []string{} + } + if manifest.Frontend.BundleSecrets == nil { + manifest.Frontend.BundleSecrets = []BundleLeak{} + } + if manifest.Frontend.RawHTMLSinks == nil { + manifest.Frontend.RawHTMLSinks = []RawHTMLSink{} + } + return manifest +} + +func hasPublicGuard(guards []string) bool { + for _, guard := range guards { + if guard == PublicGuardID { + return true + } + } + return false +} + +func endpointID(endpoint compiler.EndpointBinding) string { + for _, candidate := range []string{endpoint.Symbol, endpoint.Contract.Name, endpoint.Handler, endpoint.PageID} { + if candidate != "" { + return candidate + } + } + return endpoint.Route +} + +func bodyLimitFor(config gowdk.Config, kind compiler.EndpointKind) int64 { + switch kind { + case compiler.EndpointAPI, compiler.EndpointQuery: + return config.Build.BodyLimits.APILimitBytes() + default: + return config.Build.BodyLimits.ActionLimitBytes() + } +} + +func sourceRef(file string, span source.SourceSpan) string { + if file == "" { + return "" + } + if span.Start.Line > 0 { + return fmt.Sprintf("%s:%d", file, span.Start.Line) + } + return file +} diff --git a/internal/securitymanifest/manifest_test.go b/internal/securitymanifest/manifest_test.go new file mode 100644 index 00000000..83b0cc99 --- /dev/null +++ b/internal/securitymanifest/manifest_test.go @@ -0,0 +1,97 @@ +package securitymanifest + +import ( + "testing" + + "github.com/cssbruno/gowdk" + "github.com/cssbruno/gowdk/internal/gwdkir" + "github.com/cssbruno/gowdk/internal/source" +) + +func TestBuildProjectsRoutesAndEndpoints(t *testing.T) { + ir := gwdkir.Program{ + Routes: []gwdkir.Route{ + {Kind: gwdkir.RouteSPA, Method: "GET", Path: "/", PageID: "home", Render: gowdk.SPA, Guards: []string{"public"}, Source: "home.page.gwdk", Span: source.SourceSpan{Start: source.SourcePosition{Line: 4, Column: 1}}}, + {Kind: gwdkir.RouteSSR, Method: "GET", Path: "/dashboard", PageID: "dashboard", Render: gowdk.SSR, Guards: []string{"auth.required"}, Source: "dashboard.page.gwdk", Span: source.SourceSpan{Start: source.SourcePosition{Line: 4, Column: 1}}}, + {Kind: gwdkir.RouteSPA, Method: "GET", Path: "/draft", PageID: "draft", Render: gowdk.SPA, Source: "draft.page.gwdk"}, + }, + Endpoints: []gwdkir.Endpoint{ + {Kind: gwdkir.EndpointAction, Symbol: "Submit", Method: "POST", Path: "/signup", PageID: "signup", Guards: []string{"public"}, CSRF: false, SourceFile: "signup.page.gwdk", Span: source.SourceSpan{Start: source.SourcePosition{Line: 8, Column: 1}}}, + {Kind: gwdkir.EndpointAPI, Symbol: "Health", Method: "GET", Path: "/api/health", PageID: "status", Guards: []string{"public"}, SourceFile: "status.page.gwdk"}, + }, + } + + manifest := Build(gowdk.Config{}, ir) + + if manifest.Version != SchemaVersion || manifest.GeneratedFrom != "ir" { + t.Fatalf("unexpected manifest header: %+v", manifest) + } + if len(manifest.Routes) != 3 { + t.Fatalf("expected 3 routes, got %d", len(manifest.Routes)) + } + + byPage := map[string]RouteEntry{} + for _, route := range manifest.Routes { + byPage[route.PageID] = route + } + if home := byPage["home"]; !home.Public || home.DefaultDeny { + t.Fatalf("home should be public and not default-deny: %+v", home) + } + if dash := byPage["dashboard"]; dash.Public || dash.DefaultDeny { + t.Fatalf("dashboard should be protected (not public, not default-deny): %+v", dash) + } + if draft := byPage["draft"]; draft.Public || !draft.DefaultDeny { + t.Fatalf("draft should be default-deny (no guard): %+v", draft) + } + if draft := byPage["draft"]; draft.Source != "draft.page.gwdk" { + t.Fatalf("source without a span line should be the bare file: %q", draft.Source) + } + if home := byPage["home"]; home.Source != "home.page.gwdk:4" { + t.Fatalf("source with a span line should be file:line, got %q", home.Source) + } + + if got := manifest.Frontend.UnguardedRoutes; len(got) != 1 || got[0] != "/draft" { + t.Fatalf("expected /draft in unguardedRoutes, got %#v", got) + } + + byEndpoint := map[string]EndpointEntry{} + for _, endpoint := range manifest.Endpoints { + byEndpoint[endpoint.ID] = endpoint + } + submit, ok := byEndpoint["Submit"] + if !ok { + t.Fatalf("expected Submit endpoint, got %#v", manifest.Endpoints) + } + if submit.Kind != "action" || submit.CSRF { + t.Fatalf("Submit should be an action without CSRF: %+v", submit) + } + if submit.BodyLimitBytes != gowdk.DefaultRequestBodyLimitBytes { + t.Fatalf("Submit should carry the default action body limit, got %d", submit.BodyLimitBytes) + } + if submit.Source != "signup.page.gwdk:8" { + t.Fatalf("Submit source should be file:line, got %q", submit.Source) + } +} + +func TestBuildHonorsConfiguredBodyLimits(t *testing.T) { + config := gowdk.Config{Build: gowdk.BuildConfig{BodyLimits: gowdk.BodyLimitsConfig{ActionBytes: 256 << 10, APIBytes: 512 << 10}}} + ir := gwdkir.Program{ + Endpoints: []gwdkir.Endpoint{ + {Kind: gwdkir.EndpointAction, Symbol: "Submit", Method: "POST", Path: "/a", PageID: "p", Guards: []string{"public"}}, + {Kind: gwdkir.EndpointAPI, Symbol: "List", Method: "GET", Path: "/api/l", PageID: "p", Guards: []string{"public"}}, + }, + } + manifest := Build(config, ir) + for _, endpoint := range manifest.Endpoints { + switch endpoint.Kind { + case "action": + if endpoint.BodyLimitBytes != 256<<10 { + t.Fatalf("action body limit = %d, want %d", endpoint.BodyLimitBytes, 256<<10) + } + case "api": + if endpoint.BodyLimitBytes != 512<<10 { + t.Fatalf("api body limit = %d, want %d", endpoint.BodyLimitBytes, 512<<10) + } + } + } +} From a57321578a3511cfc9a541a5d70bfec4420e79c3 Mon Sep 17 00:00:00 2001 From: cssbruno Date: Sat, 13 Jun 2026 17:24:06 -0300 Subject: [PATCH 2/6] fix(audit): close baseline and report exposure gaps --- README.md | 2 + cmd/gowdk/build.go | 3 ++ cmd/gowdk/dev_loop.go | 3 ++ cmd/gowdk/main_test.go | 22 ++++++++++ cmd/gowdk/serve.go | 7 +++ docs/compiler/manifest.md | 16 ++++--- docs/engineering/architecture.md | 15 ++++--- docs/engineering/security.md | 19 ++++---- docs/product/requirements.md | 5 ++- docs/product/roadmap.md | 7 ++- docs/product/security-audit-spec.md | 8 ++-- docs/reference/cli.md | 19 ++++---- docs/reference/diagnostic-codes.md | 5 ++- internal/appgen/appgen_test.go | 2 + internal/appgen/files.go | 2 + internal/auditspec/baseline.go | 21 +++++++++ internal/auditspec/engine_test.go | 13 +++++- internal/buildgen/build.go | 13 +++--- internal/buildgen/build_report_test.go | 60 ++++++++++++++++++++++++++ internal/buildgen/security_manifest.go | 30 ++++++++++++- internal/diagnostics/explain.go | 9 +++- internal/diagnostics/registry.go | 3 +- runtime/app/app.go | 7 +++ runtime/app/app_test.go | 18 ++++++++ 24 files changed, 259 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 9a01e451..4ecfc18f 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,7 @@ pipeline. Run `gowdk` with no arguments for full flags. | `gowdk fix` | Apply registered safe fixes for diagnostics (`--dry-run`, `--code`) | | `gowdk explain ` | Explain a diagnostic code and its next steps | | `gowdk doctor` | Check local environment and project health | +| `gowdk audit` | Derive security posture and evaluate the built-in baseline (`--json` for CI) | | `gowdk inspect ir` / `tree` / `endpoint-graph` / `go-bindings` | Print validated compiler IR, source-linked node tree, endpoint dispatch graph, or Go binding report JSON | | `gowdk manifest` / `routes` / `sitemap` | Print validated manifest, route/endpoint metadata, or editor site-map JSON | | `gowdk tokens` | Print raw language tokens for a file | @@ -237,6 +238,7 @@ This table describes the current demoable 0.x slice. Status levels: | CSS/assets | Works, contract unstable | CSS processors, page CSS, scoped component CSS, component assets, asset manifests, content-hashed filenames, and optional Tailwind wrapper exist. | CSS processor contracts and optional dependency boundaries need hardening. | [CSS](docs/reference/css.md) | [CSS](examples/css/styled.page.gwdk) | | One-binary output | Works, contract unstable | `gowdk build --app --bin` can generate and compile an embedded Go server for supported SPA/backend/SSR slices. | Runtime operations, split/backend-only deploys, and artifact smoke coverage are still expanding. | [Deployment](docs/reference/deployment.md) | [Embed](examples/embed/site.page.gwdk) | | Contracts | Works, contract unstable | Runtime contracts support typed queries, commands, events, jobs, role filtering, local dispatch, file outbox, broker/fanout adapters, contract graph/trace/list commands, and generated `g:command`/`g:query` web adapters. | Split worker/cron generation, retry policy, managed deployment recipes, and editor-first contract visualization remain planned. | [Contracts](docs/reference/contracts.md) | [Runtime contracts](runtime/contracts) | +| Security audit | Early | `gowdk audit` derives an IR-backed posture for routes, endpoints, and contracts, evaluates the built-in baseline, exits non-zero on error findings, and `gowdk build` writes the posture to a non-served report path. | Frontend audits, declared `*.audit.gwdk` policies, and generated integration tests are planned. | [Security](docs/engineering/security.md) | [Spec](docs/product/security-audit-spec.md) | | Dev server | Works | `gowdk dev` polls inputs, skips no-op rebuilds, serves or runs generated output, live-reloads browsers, shows a browser overlay for rebuild failures, and keeps serving the last successful output. | Overlay diagnostics need codes, source spans, changed-file context, and better generated-app runtime attribution. Component HMR is intentionally deferred. | [Dev](docs/reference/dev.md) | [Getting started](docs/getting-started.md) | | Editor/LSP | Works | The VS Code extension and dependency-free LSP provide diagnostics, formatting, completions, hover, outline, semantic tokens, definitions, references, site-map visualization, and project-aware navigation for supported paths. | Exact source ranges, richer quick fixes, route/endpoint/contract maps, and `g:command`/`g:query` status in the editor are planned. | [Language server](docs/product/language-server.md) | [VS Code](editors/vscode) | diff --git a/cmd/gowdk/build.go b/cmd/gowdk/build.go index ffbc58b3..2743b03b 100644 --- a/cmd/gowdk/build.go +++ b/cmd/gowdk/build.go @@ -230,6 +230,9 @@ func buildOnce(options cliOptions, request buildRequest, timings *buildTimingRec if result.OpenAPIPath != "" { fmt.Println(result.OpenAPIPath) } + if result.SecurityManifestPath != "" { + fmt.Println(result.SecurityManifestPath) + } var asyncAPIPath string if err := timings.measure("asyncapi_report", func() error { var writeErr error diff --git a/cmd/gowdk/dev_loop.go b/cmd/gowdk/dev_loop.go index eb51ebde..8ac9932c 100644 --- a/cmd/gowdk/dev_loop.go +++ b/cmd/gowdk/dev_loop.go @@ -124,6 +124,9 @@ func buildIncrementalSPA(args []string, change inputChange) (bool, error) { if result.AssetManifestPath != "" { fmt.Println(result.AssetManifestPath) } + if result.SecurityManifestPath != "" { + fmt.Println(result.SecurityManifestPath) + } if result.BuildReportPath != "" { fmt.Println(result.BuildReportPath) } diff --git a/cmd/gowdk/main_test.go b/cmd/gowdk/main_test.go index 7f4968ba..38ad22dd 100644 --- a/cmd/gowdk/main_test.go +++ b/cmd/gowdk/main_test.go @@ -453,6 +453,10 @@ view { if !strings.Contains(stdout, reportPath) { t.Fatalf("expected stdout to include build report path %q, got:\n%s", reportPath, stdout) } + securityPath := filepath.Join(root, ".gowdk", "reports", "dist", "gowdk-security.json") + if !strings.Contains(stdout, securityPath) { + t.Fatalf("expected stdout to include security report path %q, got:\n%s", securityPath, stdout) + } if !strings.Contains(stderr, "gowdk build report (build):") { t.Fatalf("expected debug report header on stderr, got:\n%s", stderr) } @@ -462,6 +466,12 @@ view { if _, err := os.Stat(reportPath); err != nil { t.Fatalf("expected build report artifact: %v", err) } + if _, err := os.Stat(securityPath); err != nil { + t.Fatalf("expected external security report artifact: %v", err) + } + if _, err := os.Stat(filepath.Join(outputDir, "gowdk-security.json")); !os.IsNotExist(err) { + t.Fatalf("security report must not be written to served output root, stat err=%v", err) + } } func TestBuildCommandDoesNotWriteTimingsByDefault(t *testing.T) { @@ -5872,6 +5882,18 @@ func TestOutputFileHandlerDoesNotListDirectories(t *testing.T) { } } +func TestOutputFileHandlerDoesNotServeSecurityManifest(t *testing.T) { + root := t.TempDir() + writeCLIFile(t, filepath.Join(root, "gowdk-security.json"), `{"endpoints":[{"path":"/admin"}]}`) + + response := httptest.NewRecorder() + outputFileHandler(root).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/gowdk-security.json", nil)) + + if response.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d with body %s", response.Code, response.Body.String()) + } +} + func TestServeCommandRejectsMissingDirectory(t *testing.T) { err := serve([]string{"--dir", filepath.Join(t.TempDir(), "missing")}) if err == nil { diff --git a/cmd/gowdk/serve.go b/cmd/gowdk/serve.go index 1a5aa5f1..9fb56ee6 100644 --- a/cmd/gowdk/serve.go +++ b/cmd/gowdk/serve.go @@ -267,6 +267,9 @@ func outputFilePath(root, requestPath string) (string, bool) { func outputCandidatePath(root, candidate string) (string, bool) { rel := strings.TrimPrefix(path.Clean("/"+candidate), "/") + if unsafeServedOutputFile(rel) { + return "", false + } filePath := filepath.Join(root, filepath.FromSlash(rel)) relative, err := filepath.Rel(root, filePath) if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { @@ -285,3 +288,7 @@ func outputCandidatePath(root, candidate string) (string, bool) { } return filePath, true } + +func unsafeServedOutputFile(rel string) bool { + return strings.EqualFold(path.Base(filepath.ToSlash(rel)), "gowdk-security.json") +} diff --git a/docs/compiler/manifest.md b/docs/compiler/manifest.md index 3cd3b792..626ab47d 100644 --- a/docs/compiler/manifest.md +++ b/docs/compiler/manifest.md @@ -160,13 +160,15 @@ emits the referenced file. ## Current Security Manifest -`gowdk build` also writes `gowdk-security.json` in the selected output -directory. It is a declarative, IR-derived security posture: every route, -backend endpoint, and contract with its guards, CSRF state, body limit, public/ -default-deny classification, and source location, plus a `frontend` surface -block. Like the route and asset manifests, it is pure data — it never evaluates -policy. `gowdk audit` reads this same posture and applies the security baseline -(and, in later M8 phases, declared policies) to produce findings. +`gowdk build` also writes `gowdk-security.json` as a non-served report outside +the selected output directory, under a sibling +`.gowdk/reports//` directory. It is a declarative, IR-derived +security posture: every route, backend endpoint, and contract with its guards, +CSRF state, body limit, public/default-deny classification, and source +location, plus a `frontend` surface block. Like the route and asset manifests, +it is pure data — it never evaluates policy. `gowdk audit` reads this same +posture and applies the security baseline (and, in later M8 phases, declared +policies) to produce findings. ```json { diff --git a/docs/engineering/architecture.md b/docs/engineering/architecture.md index 4c8e66c4..420a93d2 100644 --- a/docs/engineering/architecture.md +++ b/docs/engineering/architecture.md @@ -6,8 +6,9 @@ GOWDK is compile-first. The current repository discovers `.gwdk` files, parses page, component, layout, endpoint, client, CSS, asset, and source-import metadata into a typed GOWDK AST, lowers that AST into compiler IR, validates route/render/component/handler contracts, emits manifest/site-map/build-report -metadata, and generates build-time SPA output, generated app source, local -binaries, and Go `js/wasm` deploy artifacts. +metadata plus non-served security posture reports, and generates build-time SPA +output, generated app source, local binaries, and Go `js/wasm` deploy +artifacts. Generated build output covers simple static pages, literal dynamic `paths {}` entries, literal, default `go {}`, and same-package/imported no-argument `build {}` data returning `T` or `(T, error)`, @@ -15,8 +16,8 @@ declared layouts, discovered components, page CSS, processor-emitted CSS, partial-update client assets, generated JavaScript island assets, component-level browser WASM island assets, route manifests, asset manifests, source-linked inspect trees, endpoint dispatch graphs, OpenAPI and AsyncAPI inspection -artifacts, and cache metadata. The build pipeline skips identical generated -writes and can +artifacts, `gowdk-security.json` posture reports outside served output, and +cache metadata. The build pipeline skips identical generated writes and can incrementally render page-only SPA edits in the dev loop. Generated apps use `runtime/app` and `net/http` handler contracts. They can @@ -144,7 +145,7 @@ manifest report (`internal/lang/testdata/manifest_golden`). | Component | Responsibility | Owner | Notes | | --- | --- | --- | --- | -| `cmd/gowdk` | CLI entrypoint. | Core | Exposes `version`, `tokens`, `fmt`, `check`, `manifest`, `sitemap`, `routes`, `endpoints`, `inspect`, `generate stubs`, `build`, `dev`, `preview`, `serve`, and `lsp`. `build` can emit spa files, generated embedded app source, an optional binary, an optional WASM artifact, and OpenAPI/AsyncAPI inspection reports for all discovered sources, selected configured modules, or spa `Build.Targets`; `inspect go-bindings` reports Go interop status for backend handlers, load functions, build-time Go calls, and web contracts; `generate stubs` writes conservative missing action/API handler stubs; `dev` compares input content hashes, can use incremental spa rendering for page-only plain output changes, persists a dev input cache, serves static output, or runs/restarts a generated app binary for backend/SSR flows; `preview` builds and serves a local deploy preview, with `--hot` reusing the dev loop. | +| `cmd/gowdk` | CLI entrypoint. | Core | Exposes `version`, `tokens`, `fmt`, `check`, `audit`, `manifest`, `sitemap`, `routes`, `endpoints`, `inspect`, `generate stubs`, `build`, `dev`, `preview`, `serve`, and `lsp`. `build` can emit spa files, generated embedded app source, an optional binary, an optional WASM artifact, OpenAPI/AsyncAPI inspection reports, and a non-served security posture report for all discovered sources, selected configured modules, or spa `Build.Targets`; `audit` evaluates the IR-derived posture against the built-in baseline and exits non-zero on error findings; `inspect go-bindings` reports Go interop status for backend handlers, load functions, build-time Go calls, and web contracts; `generate stubs` writes conservative missing action/API handler stubs; `dev` compares input content hashes, can use incremental spa rendering for page-only plain output changes, persists a dev input cache, serves static output, or runs/restarts a generated app binary for backend/SSR flows; `preview` builds and serves a local deploy preview, with `--hot` reusing the dev loop. | | `gowdk` root package | Public config, render modes, addon registration, and extension contracts. | Core | Includes `Config`, `RenderMode`, `Addon`, `CSSConfig`, `CSSProcessor`, and `GoBlockConsumer`. | | `internal/discover` | Find portable `.gwdk` files from include/exclude patterns. | Compiler | Recursive glob discovery implemented. | | `internal/gwdkast` | Define the typed GOWDK source AST. | Compiler | Package declarations, typed page/component/layout/route/render/layout/guard/CSS declarations, component CSS scope/hash metadata, metadata declarations, Go imports, GOWDK uses, stores, typed component contracts, blocks, endpoint declarations, parsed view nodes, literal records, and source spans implemented. | @@ -158,7 +159,9 @@ manifest report (`internal/lang/testdata/manifest_golden`). | `internal/lsp` | Language Server Protocol bridge for diagnostics, formatting, completions, and hover. | Tools | Dependency-free stdio server implemented with baseline and open-project completions plus hover for known language tokens and open-project symbols. | | `internal/project` | Load project-level config, module source groups, build targets, and future source roots. | Compiler | SPA `gowdk.config.go` subset implemented for build discovery, output, and `Build.Targets`; project-level CLI commands require this config or an explicit `--config` file before compiling `.gwdk` code. | | `internal/compiler` | Validate manifests and coordinate compilation metadata. | Compiler | Render-mode, duplicate identity, redundant component implementation, component Go contract, saved default `go {}` package type-checking with sibling Go files, route shape, duplicate route param, duplicate route pattern, route-method, required page-view validation, default `go {}` backend endpoint binding fallback, and `go/packages`-backed backend binding implemented. CLI route/endpoint reports now convert through `internal/gwdkir.Program`. | -| `internal/buildgen` | Emit route-derived spa HTML files for build-time pages and SSR render artifacts. | Compiler | Disk builds, memory builds, incremental SPA builds, and SSR artifact planning consume `internal/gwdkir.Program`. Initial simple page, literal build data, imported Go build data calls, literal dynamic path expansion, component expansion, partial runtime asset emission, default JS island asset emission, component-level non-CSS asset emission, component-level WASM island asset emission, page-level `go client {}` WASM mount asset emission, concrete and dynamic SSR page rendering with declared `load {}` placeholders, route manifest emission, asset manifest emission, OpenAPI report emission, mandatory build report emission with cache-policy and request-time skip events, identical-output write skipping, and incremental changed-page spa rendering implemented. | +| `internal/securitymanifest` | Project compiler IR into declarative security posture. | Tools | Builds `gowdk-security.json` posture records for routes, backend endpoints, command/query web contract endpoints, contract metadata, guards, CSRF state, body limits, public/default-deny classification, and source locations. It describes posture only; policy evaluation lives in `internal/auditspec`. | +| `internal/auditspec` | Evaluate security posture against audit policy. | Tools | Provides the policy model, selector matcher, `extends` composition, built-in baseline, and registry-backed findings for `gowdk audit`. The first baseline covers action/command CSRF, guardless action/fragment/command/query endpoints, and public-by-omission APIs; frontend audits and declared `*.audit.gwdk` policies remain planned. | +| `internal/buildgen` | Emit route-derived spa HTML files for build-time pages and SSR render artifacts. | Compiler | Disk builds, memory builds, incremental SPA builds, and SSR artifact planning consume `internal/gwdkir.Program`. Initial simple page, literal build data, imported Go build data calls, literal dynamic path expansion, component expansion, partial runtime asset emission, default JS island asset emission, component-level non-CSS asset emission, component-level WASM island asset emission, page-level `go client {}` WASM mount asset emission, concrete and dynamic SSR page rendering with declared `load {}` placeholders, route manifest emission, asset manifest emission, OpenAPI report emission, non-served security posture report emission, mandatory build report emission with cache-policy and request-time skip events, identical-output write skipping, and incremental changed-page spa rendering implemented. | | `internal/appgen` | Emit generated Go app source for embedded spa output and request-time routes. | Compiler | Auto route planning consumes `internal/gwdkir.Program`, backend adapter planning uses typed appgen IR, and generated app Go files are assembled with `go/ast`/`go/printer` before `go/format`. Generates `go.mod`, `main.go`, copied spa assets, thin `runtime/app` server wiring, `runtime/app.BackendRouter` registrations for feature-bound action/API/fragment routes, 501 stubs for missing/unsupported handlers, POST redirect and partial fragment action handlers backed by `runtime/form`, `runtime/response`, `runtime/validation`, and `addons/partial`, form input decoders, concrete and dynamic standalone fragment routes, concrete and dynamic SSR route handlers backed by `runtime/route`, declared SSR load path calls with redirect/error-page handling through `addons/ssr`, shared request-time guard checks through `runtime/guard`, generated `gowdk_go/` packages for default `go {}` and `go ssr {}` blocks, addon `GoBlockConsumer` Go files, split backend apps, command/query contract exposure metadata in adapter IR including runtime roles, identical-output write skipping, stale embedded spa cleanup, and can invoke `go build` for local binaries or Go `js/wasm` artifacts. | | `internal/clientrt` | Emit client runtime for partial updates and static-first SPA navigation. | Runtime | First partial form enhancement runtime emits lifecycle hooks, target/swap request headers, swaps, focus restoration, loading state metadata, island remounts, and page-level `go client {}` remounts after SPA navigation. | | `runtime/render` | Core rendering engine used by static output, actions, partials, and SSR. | Runtime | Renderer and generated-code builder implemented; expression text writes escape by default. | diff --git a/docs/engineering/security.md b/docs/engineering/security.md index 6e9879a1..28170498 100644 --- a/docs/engineering/security.md +++ b/docs/engineering/security.md @@ -18,8 +18,8 @@ Do not treat current `act`, `api`, `partial`, `guard`, or SSR scaffolding as com ## GOWDK-Specific Security Rules -- Generated actions must enable `Build.CSRF.Enabled` and set a stable CSRF - secret before production use. +- Generated actions and command endpoints must enable `Build.CSRF.Enabled` and + set a stable CSRF secret before production use. - Generated form decoders must validate expected fields and avoid mass assignment. - Generated action forms must reject direct file inputs and multipart posts. Uploads are user-owned API/server behavior with explicit size, storage, @@ -37,7 +37,8 @@ Do not treat current `act`, `api`, `partial`, `guard`, or SSR scaffolding as com Before generated app output is considered production-ready: -- Generated action CSRF must be enabled and configured with a runtime secret. +- Generated action and command CSRF must be enabled and configured with a + runtime secret. - Redirects must reject unsafe external destinations unless explicitly allowed. - Generated decoders must define how unknown, missing, repeated, and file fields are handled. - Guards must have a documented execution contract, failure behavior, and test coverage. @@ -51,11 +52,13 @@ Before generated app output is considered production-ready: ## Auditing The Posture `gowdk audit` makes this baseline executable. It derives a declarative security -posture from validated IR (written as `gowdk-security.json` at build time) and -evaluates a built-in policy that encodes the production-readiness gates above — -for example, actions must enforce CSRF and APIs must not be public by omission. -Findings carry a stable diagnostic code, a `file:line`, and remediation; run -`gowdk explain ` for details. +posture from validated IR. `gowdk build` writes the posture as +`gowdk-security.json` in a non-served sibling report path under +`.gowdk/reports//`, and `gowdk audit --json` includes the same +posture inline. The built-in policy encodes the production-readiness gates +above — for example, actions and commands must enforce CSRF and APIs must not +be public by omission. Findings carry a stable diagnostic code, a `file:line`, +and remediation; run `gowdk explain ` for details. `gowdk audit` is a standalone command. `gowdk build` never runs it, so it cannot fail a build implicitly; run it on demand or in CI, where its non-zero exit on diff --git a/docs/product/requirements.md b/docs/product/requirements.md index 1ebc2635..fdcd49ef 100644 --- a/docs/product/requirements.md +++ b/docs/product/requirements.md @@ -49,8 +49,9 @@ language references, compiler docs, and examples. | PRD-021 | Provide a dependency-free fast local development loop. | High | Partial | `gowdk dev` polls discovered inputs, compares content hashes, rebuilds only on real input changes, can incrementally render changed page sources for plain build output, serves the generated output, and live reloads browsers after successful rebuilds. SPA/app generation skips identical file writes. | | PRD-022 | Allow generated app output to compile to a WASM deploy artifact. | Medium | Partial | `gowdk build --wasm ` and `Build.Targets[].WASM` compile the generated app with `GOOS=js GOARCH=wasm`. This remains separate from component-level browser island assets emitted for `wasm` components. | | PRD-023 | Keep current documentation aligned with implemented CLI, config, compiler, language, routing, deployment, and examples. | High | Implemented | `README.md`, `docs/getting-started.md`, reference docs, language docs, compiler docs, and `examples/README.md` describe current support and call out planned behavior. | -| PRD-024 | Require project config before compiling or validating `.gwdk` code. | High | Implemented | `check`, `manifest`, `sitemap`, `routes`, `build`, and `dev` require `gowdk.config.go` in the current directory or an explicit `--config `, even when explicit `.gwdk` file paths are provided. | +| PRD-024 | Require project config before compiling or validating `.gwdk` code. | High | Implemented | `check`, `audit`, `manifest`, `sitemap`, `routes`, `build`, and `dev` require `gowdk.config.go` in the current directory or an explicit `--config `, even when explicit `.gwdk` file paths are provided. | | PRD-025 | Keep framework integrations optional and outside compiler/runtime core. | Medium | Implemented | Generated apps expose standard `net/http` handlers and framework-neutral code by default. Optional `runtime/adapters/echo`, `runtime/adapters/gin`, and `runtime/adapters/fiber` nested modules wrap the same generated `http.Handler`; docs cover Echo v5, Gin, Fiber, and Fiber adaptor caveats. | +| PRD-026 | Provide declarative security posture and baseline audit gating. | High | Partial | `internal/securitymanifest` projects validated IR into a route/endpoint/contract posture, `internal/auditspec` evaluates the built-in baseline, `gowdk audit` reports human/JSON findings with registry-backed severities and exits non-zero on error findings, and `gowdk build` writes `gowdk-security.json` to a non-served report path outside selected output. The baseline covers action/command CSRF, guardless action/fragment/command/query endpoints, and public-by-omission APIs; frontend audits, declared `*.audit.gwdk` policies, emitted integration tests, and runtime header verification remain planned. | ## P0/P1/P2 Decision Backlog @@ -96,7 +97,7 @@ implemented. - Performance: SPA pages should be generated at build time and served directly from disk or embedded assets. - Reliability: compiler diagnostics must fail fast for invalid render modes, SSR used without the feature enabled, and dynamic SPA routes without paths. -- Security: actions need CSRF, typed form decoding, validation, and safe redirects before production use. +- Security: state-changing generated endpoints need CSRF, typed input decoding, validation, and safe redirects before production use; audit posture reports must not be served as public build output. - Privacy: generated logs and diagnostics must not expose secrets or sensitive form data. - Packaging: generated binaries and WASM artifacts must embed only the selected module output for that build. - Developer loop: failed rebuilds must not stop the last successful served output, no-op generated writes should not retrigger dev loops, and page-local build-output edits should not force full output rendering. diff --git a/docs/product/roadmap.md b/docs/product/roadmap.md index 53e70b80..34cbe1cc 100644 --- a/docs/product/roadmap.md +++ b/docs/product/roadmap.md @@ -117,8 +117,8 @@ level, the current baseline already includes: - build-time SPA output for simple pages, dynamic `paths {}` subsets, literal, imported, same-package, and default `go {}` build data subsets, layouts, components, CSS assets, route manifests, asset manifests, build reports, generated app - output, embedded assets, local binaries, WASM deploy artifacts, and a polling - dev server with live reload; + output, non-served security posture reports, embedded assets, local binaries, + WASM deploy artifacts, and a polling dev server with live reload; - component discovery, imported props/state contracts, slots, generated JavaScript islands, component-level WASM island assets, and a first-slice client language for local component behavior; @@ -133,6 +133,9 @@ level, the current baseline already includes: standalone fragment routes, and concrete or dynamic request-time SSR pages with declared `load {}` fields through buildgen, appgen, `runtime/app`, and `runtime/route`. +- first-slice `gowdk audit` posture and baseline policy evaluation for + routes, backend endpoints, and command/query contract web endpoints, with + registry-backed findings and CI-friendly JSON output. Do not roadmap those completed slices as future work. Future work should stabilize their contracts, remove generation debt, and fill the missing diff --git a/docs/product/security-audit-spec.md b/docs/product/security-audit-spec.md index 1aacb6ef..5ef3be7f 100644 --- a/docs/product/security-audit-spec.md +++ b/docs/product/security-audit-spec.md @@ -65,7 +65,8 @@ only from the diagnostic registry, so the baseline never hardcodes severity. ## Acceptance Criteria -- [x] `gowdk-security.json` is emitted at build time and by `gowdk audit --json`. +- [x] `gowdk-security.json` is emitted to a non-served build report path and by + `gowdk audit --json`. - [x] `gowdk audit` applies the baseline, cites findings by code + `file:line`, and exits non-zero on error findings. - [x] New `audit_*` / `policy_*` codes are registered and `gowdk explain`-able. @@ -79,7 +80,8 @@ only from the diagnostic registry, so the baseline never hardcodes severity. - **Phase 0–1 (shipped in this slice):** diagnostic codes; `internal/auditspec` (composable policy model, selector matcher, `extends`, baseline, engine); `internal/securitymanifest` (IR → posture); `gowdk audit` with the baseline; - `gowdk-security.json` at build time. Unit + CLI tests. + `gowdk-security.json` at build time outside the served output directory. Unit + + CLI tests. - **Phase 2:** the four frontend audits as baseline rules. - **Phase 3:** the `*.audit.gwdk` file kind and declared composable policies. - **Phase 4:** `runtime/testkit`, generated `_test.go`, `--emit-tests`/`--run`, @@ -99,5 +101,5 @@ go test ./internal/securitymanifest ./internal/auditspec ./cmd/gowdk ./internal/ go run ./cmd/gowdk audit --json --config gowdk.config.go go run ./cmd/gowdk explain audit_api_public_by_omission go run ./cmd/gowdk build --out /tmp/gowdk-build examples/pages/home.page.gwdk \ - examples/pages/hero.cmp.gwdk && test -f /tmp/gowdk-build/gowdk-security.json + examples/pages/hero.cmp.gwdk && test -f /tmp/.gowdk/reports/gowdk-build/gowdk-security.json ``` diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 2f3fac29..8f35b676 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -56,8 +56,10 @@ gowdk lsp [--ssr] command — `gowdk build` never runs it — so it cannot fail a build implicitly. It exits non-zero when any error-severity finding exists, so it can gate CI. `--json` prints the posture manifest plus findings and a summary. Every finding - carries a diagnostic code; run `gowdk explain ` for details. The posture - alone is also written to `gowdk-security.json` at build time. + carries a diagnostic code; run `gowdk explain ` for details. + `gowdk build` also writes the posture alone to a non-served + `.gowdk/reports//gowdk-security.json` path outside the selected + output directory. - `--write`: supported by `fmt`; overwrites formatted files. - `--dry-run`: supported by `fix`; prints files with available registered fixes without writing changes. @@ -229,12 +231,13 @@ declarative posture from validated IR — every route, backend endpoint, and contract with its guards, CSRF state, body limit, and source location — and evaluates the built-in security baseline against it. The baseline encodes the production-readiness gates from `docs/engineering/security.md` (for example: -actions must enforce CSRF, APIs must not be public by omission). Findings carry -a diagnostic code, a `file:line`, and remediation; run `gowdk explain ` -for details. `audit` never runs as part of `gowdk build`, so it cannot fail a -build implicitly; run it on demand or wire it into CI, where its non-zero exit -on error findings gates the pipeline. The posture alone is also emitted as -`gowdk-security.json` by `gowdk build`. +actions and commands must enforce CSRF, APIs must not be public by omission). +Findings carry a diagnostic code, a `file:line`, and remediation; run +`gowdk explain ` for details. `audit` never runs as part of `gowdk build`, +so it cannot fail a build implicitly; run it on demand or wire it into CI, +where its non-zero exit on error findings gates the pipeline. The posture alone +is also emitted as `gowdk-security.json` by `gowdk build`, but outside the +selected output directory in a non-served `.gowdk/reports//` path. `--wasm` produces a Go `js/wasm` compile artifact from the generated app. This is a deploy artifact for hosts that can run Go WebAssembly; it is separate from diff --git a/docs/reference/diagnostic-codes.md b/docs/reference/diagnostic-codes.md index b37487f6..89d42ac1 100644 --- a/docs/reference/diagnostic-codes.md +++ b/docs/reference/diagnostic-codes.md @@ -157,8 +157,9 @@ Parser diagnostics emit stable codes for common unsupported syntax and keep `client_go_block_wasm_import_error`, `client_go_block_wasm_export_error`. - Security audit (`gowdk audit`): `audit_action_missing_csrf`, - `audit_api_public_by_omission`, `audit_guardless_endpoint_page`, - `audit_bundle_secret`, `audit_client_route_unguarded`, + `audit_api_public_by_omission`, `audit_command_missing_csrf`, + `audit_guardless_endpoint_page`, `audit_bundle_secret`, + `audit_client_route_unguarded`, `audit_headers_missing`, `audit_headers_runtime_missing`, `audit_raw_html_sink`, `audit_max_body_exceeds_policy`, `audit_public_not_allowed`, `audit_required_guard_missing`, diff --git a/internal/appgen/appgen_test.go b/internal/appgen/appgen_test.go index fd9a554c..30eca489 100644 --- a/internal/appgen/appgen_test.go +++ b/internal/appgen/appgen_test.go @@ -185,6 +185,7 @@ func TestGenerateSkipsUnsafeEmbeddedOutputFiles(t *testing.T) { writeTestFile(t, filepath.Join(outputDir, "keys", "id_ed25519"), "private key") writeTestFile(t, filepath.Join(outputDir, "keys", "ID_RSA"), "private key") writeTestFile(t, filepath.Join(outputDir, ".npmrc"), "//registry.example/:_authToken=secret") + writeTestFile(t, filepath.Join(outputDir, "gowdk-security.json"), `{"endpoints":[{"path":"/admin"}]}`) writeTestFile(t, filepath.Join(outputDir, "assets", "scratch.tmp"), "temporary") writeTestFile(t, filepath.Join(outputDir, "assets", "app.css"), "body{}") @@ -205,6 +206,7 @@ func TestGenerateSkipsUnsafeEmbeddedOutputFiles(t *testing.T) { filepath.Join(result.OutputDir, "tmp", "asset.css"), filepath.Join(result.OutputDir, "private", "notes.txt"), filepath.Join(result.OutputDir, "secrets", "config.json"), + filepath.Join(result.OutputDir, "gowdk-security.json"), filepath.Join(result.OutputDir, "keys", "server.key"), filepath.Join(result.OutputDir, "keys", "server.pem"), filepath.Join(result.OutputDir, "keys", "server-upper.PEM"), diff --git a/internal/appgen/files.go b/internal/appgen/files.go index b90da4ac..fb76e69f 100644 --- a/internal/appgen/files.go +++ b/internal/appgen/files.go @@ -95,6 +95,8 @@ func unsafeEmbeddedFile(rel string) bool { switch { case normalizedBase == ".env" || strings.HasPrefix(normalizedBase, ".env."): return true + case normalizedBase == "gowdk-security.json": + return true case normalizedBase == ".npmrc" || normalizedBase == ".netrc": return true case privateKeyFile(normalizedBase): diff --git a/internal/auditspec/baseline.go b/internal/auditspec/baseline.go index 46948225..eb9c4b6d 100644 --- a/internal/auditspec/baseline.go +++ b/internal/auditspec/baseline.go @@ -41,6 +41,27 @@ func Baseline() []Policy { {Kind: RuleRequireAnyGuard, Code: "audit_api_public_by_omission"}, }, }, + { + Name: "baseline.contract_commands", + Builtin: true, + Selectors: []Selector{ + {Raw: "command:*", Kind: SelectorEndpoint}, + }, + Rules: []Rule{ + {Kind: RuleRequireCSRF, Code: "audit_command_missing_csrf"}, + {Kind: RuleRequireAnyGuard, Code: "audit_guardless_endpoint_page"}, + }, + }, + { + Name: "baseline.contract_queries", + Builtin: true, + Selectors: []Selector{ + {Raw: "query:*", Kind: SelectorEndpoint}, + }, + Rules: []Rule{ + {Kind: RuleRequireAnyGuard, Code: "audit_guardless_endpoint_page"}, + }, + }, { Name: "baseline.frontend", Builtin: true, diff --git a/internal/auditspec/engine_test.go b/internal/auditspec/engine_test.go index 4e980c3c..6c6f1f00 100644 --- a/internal/auditspec/engine_test.go +++ b/internal/auditspec/engine_test.go @@ -21,6 +21,8 @@ func TestBaselineFlagsMissingCSRFAndPublicAPI(t *testing.T) { {ID: "Submit", Kind: "action", Method: "POST", Path: "/signup", Guards: []string{"public"}, CSRF: false, Public: true}, {ID: "Health", Kind: "api", Method: "GET", Path: "/api/health", CSRF: false, DefaultDeny: true}, {ID: "Refresh", Kind: "fragment", Method: "GET", Path: "/frag", DefaultDeny: true}, + {ID: "patients.CreatePatient", Kind: "command", Method: "POST", Path: "/patients", CSRF: false, DefaultDeny: true}, + {ID: "patients.GetPatientPage", Kind: "query", Method: "GET", Path: "/patients", CSRF: false, DefaultDeny: true}, }, Frontend: securitymanifest.FrontendSurface{}, } @@ -32,8 +34,11 @@ func TestBaselineFlagsMissingCSRFAndPublicAPI(t *testing.T) { if got["audit_api_public_by_omission"] != 1 { t.Fatalf("expected one public-by-omission API finding, got %d", got["audit_api_public_by_omission"]) } - if got["audit_guardless_endpoint_page"] != 1 { - t.Fatalf("expected one guardless fragment finding, got %d", got["audit_guardless_endpoint_page"]) + if got["audit_command_missing_csrf"] != 1 { + t.Fatalf("expected one missing-CSRF command finding, got %d", got["audit_command_missing_csrf"]) + } + if got["audit_guardless_endpoint_page"] != 3 { + t.Fatalf("expected three guardless fragment/contract findings, got %d", got["audit_guardless_endpoint_page"]) } } @@ -42,6 +47,8 @@ func TestBaselinePassesWhenPostureIsSound(t *testing.T) { Endpoints: []securitymanifest.EndpointEntry{ {ID: "Submit", Kind: "action", Method: "POST", Path: "/signup", Guards: []string{"auth.required"}, CSRF: true}, {ID: "List", Kind: "api", Method: "GET", Path: "/api/list", Guards: []string{"permission:list.read"}}, + {ID: "patients.CreatePatient", Kind: "command", Method: "POST", Path: "/patients", Guards: []string{"auth.required"}, CSRF: true}, + {ID: "patients.GetPatientPage", Kind: "query", Method: "GET", Path: "/patients", Guards: []string{"auth.required"}}, }, } findings := Evaluate(manifest, Baseline()) @@ -182,6 +189,8 @@ func TestParseSelectorClassifies(t *testing.T) { {"/admin/**", SelectorRoute}, {"act:*", SelectorEndpoint}, {"api:Health", SelectorEndpoint}, + {"command:*", SelectorEndpoint}, + {"query:*", SelectorEndpoint}, {"frontend", SelectorFrontend}, {"nonsense", SelectorUnknown}, } diff --git a/internal/buildgen/build.go b/internal/buildgen/build.go index 5deb0849..90a789bf 100644 --- a/internal/buildgen/build.go +++ b/internal/buildgen/build.go @@ -222,6 +222,11 @@ func buildMemoryFromIR(config gowdk.Config, ir gwdkir.Program, backendBindings [ }, Files: map[string][]byte{}, } + manifestPath, err := securityManifestPath(outputDir) + if err != nil { + return MemoryResult{}, reporter.fail("manifest", err) + } + result.SecurityManifestPath = manifestPath for _, artifact := range planned.css { rel, err := relativeOutputPath(outputDir, artifact.Path) if err != nil { @@ -279,13 +284,7 @@ func buildMemoryFromIR(config gowdk.Config, ir gwdkir.Program, backendBindings [ } result.Files[openAPIFile] = openAPI reporter.info("report", "openapi_collected", "OpenAPI report collected", BuildEvent{Path: openAPIFile}) - securityManifest, err := securityManifestPayload(config, ir) - if err != nil { - return MemoryResult{}, reporter.fail("manifest", err) - } - result.Files[securityManifestFile] = securityManifest - result.SecurityManifestPath = filepath.Join(outputDir, securityManifestFile) - reporter.info("manifest", "security_manifest_collected", "security manifest collected", BuildEvent{Path: securityManifestFile}) + reporter.info("manifest", "security_manifest_planned", "security manifest planned outside served output", BuildEvent{Path: eventPath(outputDir, result.SecurityManifestPath)}) reporter.info("complete", "build_complete", "in-memory SPA build completed", BuildEvent{ Data: map[string]string{ "pages": fmt.Sprint(len(result.Artifacts)), diff --git a/internal/buildgen/build_report_test.go b/internal/buildgen/build_report_test.go index 0715fb87..61664b88 100644 --- a/internal/buildgen/build_report_test.go +++ b/internal/buildgen/build_report_test.go @@ -42,6 +42,16 @@ func TestBuildWritesSPAHTMLForSimpleRoute(t *testing.T) { if result.BuildReportPath != filepath.Join(outputDir, buildReportFile) { t.Fatalf("expected build report path, got %q", result.BuildReportPath) } + expectedSecurityManifestPath, err := securityManifestPath(outputDir) + if err != nil { + t.Fatal(err) + } + if result.SecurityManifestPath != expectedSecurityManifestPath { + t.Fatalf("expected external security manifest path %q, got %q", expectedSecurityManifestPath, result.SecurityManifestPath) + } + if strings.HasPrefix(result.SecurityManifestPath, outputDir+string(filepath.Separator)) { + t.Fatalf("security manifest must not live under served output dir: %q", result.SecurityManifestPath) + } if result.Report.Version != 1 || result.Report.Mode != "build" { t.Fatalf("unexpected build report: %#v", result.Report) } @@ -107,6 +117,17 @@ func TestBuildWritesSPAHTMLForSimpleRoute(t *testing.T) { if report.Mode != "build" || !hasBuildReportEvent(report, "complete", "build_complete") { t.Fatalf("unexpected build report payload: %s", reportPayload) } + + securityPayload, err := os.ReadFile(result.SecurityManifestPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(securityPayload), `"generatedFrom": "ir"`) { + t.Fatalf("expected security manifest payload, got:\n%s", securityPayload) + } + if _, err := os.Stat(filepath.Join(outputDir, securityManifestFile)); !os.IsNotExist(err) { + t.Fatalf("security manifest must not be written to served output root, stat err=%v", err) + } } func TestBuildEmitsSPANavigationRuntimeForInternalLinks(t *testing.T) { @@ -257,6 +278,13 @@ func TestBuildMemoryReturnsSPAArtifactsWithoutWriting(t *testing.T) { if result.BuildReportPath != filepath.Join(outputDir, buildReportFile) { t.Fatalf("expected build report path, got %q", result.BuildReportPath) } + expectedSecurityManifestPath, err := securityManifestPath(outputDir) + if err != nil { + t.Fatal(err) + } + if result.SecurityManifestPath != expectedSecurityManifestPath { + t.Fatalf("expected external security manifest path %q, got %q", expectedSecurityManifestPath, result.SecurityManifestPath) + } if result.Report.Version != 1 || result.Report.Mode != "memory" { t.Fatalf("unexpected memory build report: %#v", result.Report) } @@ -277,6 +305,38 @@ func TestBuildMemoryReturnsSPAArtifactsWithoutWriting(t *testing.T) { if !strings.Contains(string(result.Files[buildReportFile]), `"mode": "memory"`) { t.Fatalf("expected build report in memory result: %s", result.Files[buildReportFile]) } + if _, ok := result.Files[securityManifestFile]; ok { + t.Fatalf("security manifest must not be returned as a served memory output file") + } + if _, err := os.Stat(result.SecurityManifestPath); !os.IsNotExist(err) { + t.Fatalf("memory build should not write the external security manifest, stat error = %v", err) + } +} + +func TestBuildRemovesStaleServedSecurityManifest(t *testing.T) { + outputDir := t.TempDir() + if err := os.WriteFile(filepath.Join(outputDir, securityManifestFile), []byte(`{"stale":true}`), 0o644); err != nil { + t.Fatal(err) + } + app := gwdkanalysis.Sources{Pages: []gwdkir.Page{{ + ID: "home", + Route: "/", + Blocks: gwdkir.Blocks{ + View: true, + ViewBody: `
Home
`, + }, + }}} + + result, err := Build(gowdk.Config{}, app, outputDir) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(outputDir, securityManifestFile)); !os.IsNotExist(err) { + t.Fatalf("expected stale served security manifest to be removed, stat err=%v", err) + } + if _, err := os.Stat(result.SecurityManifestPath); err != nil { + t.Fatalf("expected external security manifest to be written: %v", err) + } } func TestBuildReportIncludesContractReferences(t *testing.T) { diff --git a/internal/buildgen/security_manifest.go b/internal/buildgen/security_manifest.go index ec904f6d..7d6c7121 100644 --- a/internal/buildgen/security_manifest.go +++ b/internal/buildgen/security_manifest.go @@ -2,6 +2,7 @@ package buildgen import ( "encoding/json" + "os" "path/filepath" "github.com/cssbruno/gowdk" @@ -23,9 +24,36 @@ func writeSecurityManifest(outputDir string, config gowdk.Config, ir gwdkir.Prog if err != nil { return "", err } - manifestPath := filepath.Join(outputDir, securityManifestFile) + manifestPath, err := securityManifestPath(outputDir) + if err != nil { + return "", err + } if err := writeFileIfChanged(manifestPath, payload); err != nil { return "", err } + if err := removeServedSecurityManifest(outputDir); err != nil { + return "", err + } return manifestPath, nil } + +func securityManifestPath(outputDir string) (string, error) { + absOutput, err := filepath.Abs(outputDir) + if err != nil { + return "", err + } + cleanOutput := filepath.Clean(absOutput) + outputName := filepath.Base(cleanOutput) + if outputName == "" || outputName == "." || outputName == string(filepath.Separator) { + outputName = "root" + } + return filepath.Join(filepath.Dir(cleanOutput), ".gowdk", "reports", outputName, securityManifestFile), nil +} + +func removeServedSecurityManifest(outputDir string) error { + servedPath := filepath.Join(outputDir, securityManifestFile) + if err := os.Remove(servedPath); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} diff --git a/internal/diagnostics/explain.go b/internal/diagnostics/explain.go index e4b53caf..1c293f66 100644 --- a/internal/diagnostics/explain.go +++ b/internal/diagnostics/explain.go @@ -241,8 +241,15 @@ guard public "Serve the route through the generated Go server, which enforces the deny registry.", }, }, + "audit_command_missing_csrf": { + Details: "gowdk audit derives a generated command endpoint that accepts a state-changing web request without CSRF enforcement. The built-in security baseline treats this as an error because command POSTs are cross-site-forgeable in the same way as action POSTs.", + NextSteps: []string{ + "Set Build.CSRF.Enabled and a runtime CSRF secret so generated command endpoints validate tokens before decoding.", + "Use an audit policy waiver with a documented reason if the endpoint is intentionally exempt.", + }, + }, "audit_guardless_endpoint_page": { - Details: "A page that declares act, api, or fragment endpoints has no guard. Those endpoints would be publicly callable even though the page GET route is denied, which contradicts default-deny.", + Details: "A page that declares backend endpoints has no guard. Actions, fragments, commands, queries, and APIs would be publicly callable even when the page GET route is denied, which contradicts default-deny.", NextSteps: []string{ "Add a guard to the page so its derived endpoints inherit it.", "Use guard public only when every derived endpoint is intentionally unauthenticated.", diff --git a/internal/diagnostics/registry.go b/internal/diagnostics/registry.go index c18c4a25..f8a4055a 100644 --- a/internal/diagnostics/registry.go +++ b/internal/diagnostics/registry.go @@ -64,7 +64,8 @@ var Registry = []Code{ {Code: "audit_api_public_by_omission", Area: "audit", Stability: StabilityExperimental, Severity: SeverityError, Summary: "API endpoint inherits no protective guard and policy forbids public-by-omission APIs"}, {Code: "audit_bundle_secret", Area: "audit", Stability: StabilityExperimental, Severity: SeverityError, Summary: "embedded build output or build-time data carries a secret-shaped value"}, {Code: "audit_client_route_unguarded", Area: "audit", Stability: StabilityExperimental, Severity: SeverityError, Summary: "a client or SPA route is not covered by the generated default-deny registry"}, - {Code: "audit_guardless_endpoint_page", Area: "audit", Stability: StabilityExperimental, Severity: SeverityError, Summary: "a page exposing act, api, or fragment endpoints declares no guard"}, + {Code: "audit_command_missing_csrf", Area: "audit", Stability: StabilityExperimental, Severity: SeverityError, Summary: "command endpoint does not enforce CSRF as required by the security baseline or policy"}, + {Code: "audit_guardless_endpoint_page", Area: "audit", Stability: StabilityExperimental, Severity: SeverityError, Summary: "a page exposing backend endpoints declares no guard"}, {Code: "audit_headers_missing", Area: "audit", Stability: StabilityExperimental, Severity: SeverityWarning, Summary: "generated app does not declare a security response header required by policy"}, {Code: "audit_headers_runtime_missing", Area: "audit", Stability: StabilityExperimental, Severity: SeverityError, Summary: "running app did not emit a security response header required by policy"}, {Code: "audit_max_body_exceeds_policy", Area: "audit", Stability: StabilityExperimental, Severity: SeverityWarning, Summary: "endpoint request body limit is larger than the policy maximum"}, diff --git a/runtime/app/app.go b/runtime/app/app.go index b9e36b58..c000d3e9 100644 --- a/runtime/app/app.go +++ b/runtime/app/app.go @@ -590,6 +590,9 @@ func readSPAFile(root fs.FS, name string) ([]byte, fs.FileInfo, bool) { if name == "" { name = "index.html" } + if unsafeSPAFile(name) { + return nil, nil, false + } info, err := fs.Stat(root, name) if err != nil { return nil, nil, false @@ -607,3 +610,7 @@ func readSPAFile(root fs.FS, name string) ([]byte, fs.FileInfo, bool) { } return payload, info, true } + +func unsafeSPAFile(name string) bool { + return strings.EqualFold(path.Base(name), "gowdk-security.json") +} diff --git a/runtime/app/app_test.go b/runtime/app/app_test.go index b6fe0194..21544d31 100644 --- a/runtime/app/app_test.go +++ b/runtime/app/app_test.go @@ -178,6 +178,24 @@ func TestHandlerAppliesAssetManifestCachePolicy(t *testing.T) { } } +func TestHandlerDoesNotServeSecurityManifest(t *testing.T) { + handler := Handler{ + Root: fstest.MapFS{ + "gowdk-security.json": {Data: []byte(`{"endpoints":[{"path":"/admin"}]}`)}, + }, + Identity: Identity{AppID: "clinic", ModuleName: "frontend", InstanceID: "frontend-1"}, + Assets: asset.Manifest{Version: 1, Files: map[string]string{}}, + } + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/gowdk-security.json", nil) + + handler.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d with body %s", recorder.Code, recorder.Body.String()) + } +} + func TestHandlerRedirectsTrailingSlashGETToCanonicalPath(t *testing.T) { handler := Handler{ Root: fstest.MapFS{"blog/hello/index.html": {Data: []byte("
Hello
")}}, From f75fc42dbb27e5ee33f746d475fd119292448c11 Mon Sep 17 00:00:00 2001 From: cssbruno Date: Sat, 13 Jun 2026 22:54:41 -0300 Subject: [PATCH 3/6] feat(audit): complete declarative audit engine --- README.md | 4 +- cmd/gowdk/audit.go | 141 ++++++- cmd/gowdk/audit_test.go | 221 ++++++++++ cmd/gowdk/main.go | 2 +- docs/compiler/generated-output.md | 3 + docs/compiler/manifest.md | 10 +- docs/engineering/architecture.md | 11 +- docs/engineering/security.md | 7 +- docs/language/grammar.md | 19 + docs/product/requirements.md | 2 +- docs/product/roadmap.md | 7 +- docs/product/security-audit-spec.md | 15 +- docs/reference/cli.md | 13 +- docs/reference/config.md | 20 +- docs/reference/deployment.md | 7 +- docs/reference/diagnostic-codes.md | 4 +- docs/reference/testing.md | 7 + examples/README.md | 9 +- examples/security/security.audit.gwdk | 12 + gowdk.go | 9 + internal/appgen/appgen.go | 13 + internal/appgen/appgen_test.go | 94 +++++ internal/appgen/audit_tests.go | 399 ++++++++++++++++++ internal/appgen/files.go | 45 +- internal/appgen/source.go | 38 ++ internal/auditspec/auditspec.go | 6 + internal/auditspec/baseline.go | 2 + internal/auditspec/engine.go | 31 +- internal/auditspec/engine_test.go | 68 +++ internal/auditspec/ir.go | 63 +++ internal/gwdkanalysis/ir_builder.go | 10 + internal/gwdkast/audit.go | 41 ++ internal/gwdkir/ir.go | 42 ++ internal/lang/completion.go | 7 + internal/lang/redact.go | 38 +- internal/lang/tools.go | 37 ++ internal/lang/tools_test.go | 2 + internal/parser/audit.go | 375 ++++++++++++++++ internal/parser/audit_test.go | 55 +++ .../parser/testdata/golden/audit.golden.json | 179 ++++++++ .../testdata/golden/security.audit.gwdk | 16 + internal/project/config.go | 52 +++ internal/project/config_test.go | 10 + internal/safeasset/safeasset.go | 60 +++ internal/securitymanifest/manifest.go | 152 ++++++- internal/securitymanifest/manifest_test.go | 47 ++- internal/securitytext/securitytext.go | 55 +++ runtime/app/app.go | 34 +- runtime/app/app_test.go | 28 ++ runtime/app/redact.go | 38 +- runtime/testkit/testkit.go | 86 ++++ runtime/testkit/testkit_test.go | 31 ++ 52 files changed, 2486 insertions(+), 191 deletions(-) create mode 100644 examples/security/security.audit.gwdk create mode 100644 internal/appgen/audit_tests.go create mode 100644 internal/auditspec/ir.go create mode 100644 internal/gwdkast/audit.go create mode 100644 internal/parser/audit.go create mode 100644 internal/parser/audit_test.go create mode 100644 internal/parser/testdata/golden/audit.golden.json create mode 100644 internal/parser/testdata/golden/security.audit.gwdk create mode 100644 internal/safeasset/safeasset.go create mode 100644 internal/securitytext/securitytext.go create mode 100644 runtime/testkit/testkit.go create mode 100644 runtime/testkit/testkit_test.go diff --git a/README.md b/README.md index 4ecfc18f..d967a099 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,7 @@ pipeline. Run `gowdk` with no arguments for full flags. | `gowdk fix` | Apply registered safe fixes for diagnostics (`--dry-run`, `--code`) | | `gowdk explain ` | Explain a diagnostic code and its next steps | | `gowdk doctor` | Check local environment and project health | -| `gowdk audit` | Derive security posture and evaluate the built-in baseline (`--json` for CI) | +| `gowdk audit` | Derive security posture, evaluate baseline/declared policies, and optionally emit/run audit tests (`--json` for CI) | | `gowdk inspect ir` / `tree` / `endpoint-graph` / `go-bindings` | Print validated compiler IR, source-linked node tree, endpoint dispatch graph, or Go binding report JSON | | `gowdk manifest` / `routes` / `sitemap` | Print validated manifest, route/endpoint metadata, or editor site-map JSON | | `gowdk tokens` | Print raw language tokens for a file | @@ -238,7 +238,7 @@ This table describes the current demoable 0.x slice. Status levels: | CSS/assets | Works, contract unstable | CSS processors, page CSS, scoped component CSS, component assets, asset manifests, content-hashed filenames, and optional Tailwind wrapper exist. | CSS processor contracts and optional dependency boundaries need hardening. | [CSS](docs/reference/css.md) | [CSS](examples/css/styled.page.gwdk) | | One-binary output | Works, contract unstable | `gowdk build --app --bin` can generate and compile an embedded Go server for supported SPA/backend/SSR slices. | Runtime operations, split/backend-only deploys, and artifact smoke coverage are still expanding. | [Deployment](docs/reference/deployment.md) | [Embed](examples/embed/site.page.gwdk) | | Contracts | Works, contract unstable | Runtime contracts support typed queries, commands, events, jobs, role filtering, local dispatch, file outbox, broker/fanout adapters, contract graph/trace/list commands, and generated `g:command`/`g:query` web adapters. | Split worker/cron generation, retry policy, managed deployment recipes, and editor-first contract visualization remain planned. | [Contracts](docs/reference/contracts.md) | [Runtime contracts](runtime/contracts) | -| Security audit | Early | `gowdk audit` derives an IR-backed posture for routes, endpoints, and contracts, evaluates the built-in baseline, exits non-zero on error findings, and `gowdk build` writes the posture to a non-served report path. | Frontend audits, declared `*.audit.gwdk` policies, and generated integration tests are planned. | [Security](docs/engineering/security.md) | [Spec](docs/product/security-audit-spec.md) | +| Security audit | Early | `gowdk audit` derives an IR-backed posture for routes, endpoints, contracts, and frontend surface risks; evaluates the built-in baseline plus declared `*.audit.gwdk` policies; exits non-zero on error findings; can emit/run generated audit tests; and `gowdk build` writes the posture to a non-served report path. | The audit DSL and generated tests cover the M8 slice; broader auth/session ownership, richer role fixtures, and deeper browser/data-flow analysis remain app-owned or planned. | [Security](docs/engineering/security.md) | [Spec](docs/product/security-audit-spec.md) | | Dev server | Works | `gowdk dev` polls inputs, skips no-op rebuilds, serves or runs generated output, live-reloads browsers, shows a browser overlay for rebuild failures, and keeps serving the last successful output. | Overlay diagnostics need codes, source spans, changed-file context, and better generated-app runtime attribution. Component HMR is intentionally deferred. | [Dev](docs/reference/dev.md) | [Getting started](docs/getting-started.md) | | Editor/LSP | Works | The VS Code extension and dependency-free LSP provide diagnostics, formatting, completions, hover, outline, semantic tokens, definitions, references, site-map visualization, and project-aware navigation for supported paths. | Exact source ranges, richer quick fixes, route/endpoint/contract maps, and `g:command`/`g:query` status in the editor are planned. | [Language server](docs/product/language-server.md) | [VS Code](editors/vscode) | diff --git a/cmd/gowdk/audit.go b/cmd/gowdk/audit.go index 6132c663..b51982df 100644 --- a/cmd/gowdk/audit.go +++ b/cmd/gowdk/audit.go @@ -4,18 +4,23 @@ import ( "encoding/json" "fmt" "os" + "os/exec" + "path/filepath" "strings" + "github.com/cssbruno/gowdk/internal/appgen" "github.com/cssbruno/gowdk/internal/auditspec" + "github.com/cssbruno/gowdk/internal/diagnostics" + "github.com/cssbruno/gowdk/internal/gwdkir" "github.com/cssbruno/gowdk/internal/lang" "github.com/cssbruno/gowdk/internal/securitymanifest" ) -const auditUsage = "usage: gowdk audit [--config ] [--module ] [--ssr] [--json] [files...]" +const auditUsage = "usage: gowdk audit [--config ] [--module ] [--ssr] [--json] [--emit-tests[=]] [--run] [files...]" // auditReport is the gowdk audit result: the derived security posture plus the -// findings from evaluating the built-in baseline (and, later, declared -// policies) against it. +// findings from evaluating the built-in baseline and declared policies against +// it. type auditReport struct { Version int `json:"version"` Status string `json:"status"` @@ -33,6 +38,12 @@ type auditSummary struct { Info int `json:"info"` } +type auditCommandOptions struct { + EmitTests bool + RunTests bool + TestPath string +} + type auditExitError struct { errors int } @@ -48,7 +59,11 @@ func (auditExitError) SilentCLIError() {} // build never runs it, so it can never fail a build implicitly. It exits // non-zero when any error-severity finding exists so it can gate CI. func audit(args []string) error { - options, paths, err := loadCommandInputs(args, "audit", true) + auditOptions, projectArgs, err := parseAuditCommandOptions(args) + if err != nil { + return err + } + options, paths, err := loadCommandInputs(projectArgs, "audit", true) if err != nil { return err } @@ -67,7 +82,13 @@ func audit(args []string) error { } manifest := securitymanifest.Build(options.Config, ir) - findings := auditspec.Evaluate(manifest, auditspec.Baseline()) + declared := auditspec.PoliciesFromIR(ir.AuditSpecs) + findings := auditspec.Evaluate(manifest, auditspec.ComposeBaseline(declared)) + testFindings, err := handleAuditTests(auditOptions, options, manifest, ir.AuditSpecs) + if err != nil { + return err + } + findings = append(findings, testFindings...) auditspec.SortFindings(findings) report := buildAuditReport(manifest, findings) @@ -87,6 +108,116 @@ func audit(args []string) error { return nil } +func parseAuditCommandOptions(args []string) (auditCommandOptions, []string, error) { + options := auditCommandOptions{TestPath: "gowdk_audit_test.go"} + var projectArgs []string + for _, arg := range args { + switch { + case arg == "--emit-tests": + options.EmitTests = true + case strings.HasPrefix(arg, "--emit-tests="): + options.EmitTests = true + options.TestPath = strings.TrimSpace(strings.TrimPrefix(arg, "--emit-tests=")) + if options.TestPath == "" { + return options, nil, fmt.Errorf(auditUsage) + } + case arg == "--run": + options.RunTests = true + default: + projectArgs = append(projectArgs, arg) + } + } + return options, projectArgs, nil +} + +func handleAuditTests(auditOptions auditCommandOptions, options cliOptions, manifest securitymanifest.SecurityManifest, specs []gwdkir.AuditSpec) ([]auditspec.Finding, error) { + if !auditOptions.EmitTests && !auditOptions.RunTests { + return nil, nil + } + source, err := appgen.StandaloneAuditTestSource(options.Config, manifest, specs) + if err != nil { + return nil, err + } + if len(source) == 0 { + return nil, nil + } + + testPath := auditOptions.TestPath + if !filepath.IsAbs(testPath) { + testPath = filepath.Join(options.ProjectRoot, testPath) + } + if auditOptions.EmitTests { + if err := os.MkdirAll(filepath.Dir(testPath), 0o755); err != nil { + return nil, err + } + if err := os.WriteFile(testPath, source, 0o644); err != nil { + return nil, err + } + fmt.Fprintf(os.Stderr, "wrote audit tests: %s\n", testPath) + } + + if !auditOptions.RunTests { + return nil, nil + } + + runPath := testPath + removeAfterRun := false + if !auditOptions.EmitTests { + temp, err := os.CreateTemp(options.ProjectRoot, "gowdk_audit_*_test.go") + if err != nil { + return nil, err + } + runPath = temp.Name() + removeAfterRun = true + if _, err := temp.Write(source); err != nil { + _ = temp.Close() + return nil, err + } + if err := temp.Close(); err != nil { + return nil, err + } + } + if removeAfterRun { + defer os.Remove(runPath) + } + + output, err := runAuditTestFile(options.ProjectRoot, runPath) + if err == nil { + fmt.Fprintf(os.Stderr, "audit tests passed: %s\n", runPath) + return nil, nil + } + return []auditspec.Finding{{ + Code: "audit_test_failed", + Severity: auditDiagnosticSeverity("audit_test_failed"), + Target: "runtime", + Source: runPath, + Message: "generated audit integration tests failed", + Remediation: "Run go test on the emitted audit test file, then update runtime behavior or policy expectations.", + }}, writeAuditRunOutput(output) +} + +func runAuditTestFile(projectRoot, testPath string) (string, error) { + command := exec.Command("go", "test", testPath) + command.Dir = projectRoot + output, err := command.CombinedOutput() + return string(output), err +} + +func writeAuditRunOutput(output string) error { + output = strings.TrimSpace(output) + if output != "" { + fmt.Fprintln(os.Stderr, output) + } + return nil +} + +func auditDiagnosticSeverity(code string) diagnostics.Severity { + if severity, ok := diagnostics.DefaultSeverity(code); ok { + return severity + } + return diagnostics.SeverityError +} + func buildAuditReport(manifest securitymanifest.SecurityManifest, findings []auditspec.Finding) auditReport { summary := auditspec.Summarize(findings) if findings == nil { diff --git a/cmd/gowdk/audit_test.go b/cmd/gowdk/audit_test.go index 35edf684..e176b569 100644 --- a/cmd/gowdk/audit_test.go +++ b/cmd/gowdk/audit_test.go @@ -2,7 +2,9 @@ package main import ( "encoding/json" + "os" "path/filepath" + "strings" "testing" ) @@ -85,3 +87,222 @@ view { t.Fatalf("expected audit_action_missing_csrf finding, got %#v", report.Findings) } } + +func TestAuditCommandAppliesDeclaredAuditPolicy(t *testing.T) { + root := t.TempDir() + config := writeMinimalCLIConfig(t, root) + pagePath := filepath.Join(root, "admin.page.gwdk") + writeCLIFile(t, pagePath, `package app + +page admin +route "/admin" + +view { +
Admin
+} +`) + auditPath := filepath.Join(root, "security.audit.gwdk") + writeCLIFile(t, auditPath, `package app + +policy admin { + match "/admin" + require guard "role:admin" +} +`) + + stdout, _, err := captureCLIOutput(t, func() error { + return run([]string{"audit", "--json", "--config", config, pagePath, auditPath}) + }) + if err == nil { + t.Fatal("expected declared policy to fail audit") + } + var report auditReport + if err := json.Unmarshal([]byte(stdout), &report); err != nil { + t.Fatalf("expected JSON audit output, got %q: %v", stdout, err) + } + found := false + for _, finding := range report.Findings { + if finding.Code == "audit_required_guard_missing" && finding.Policy == "admin" && finding.Source == pagePath+":4" { + found = true + } + } + if !found { + t.Fatalf("expected declared policy guard finding with page source, got %#v", report.Findings) + } +} + +func TestAuditCommandReportsDeclaredPolicyResolutionFindings(t *testing.T) { + root := t.TempDir() + config := writeMinimalCLIConfig(t, root) + pagePath := filepath.Join(root, "home.page.gwdk") + writeCLIFile(t, pagePath, `package app + +page home +route "/" + +view { +
Home
+} +`) + auditPath := filepath.Join(root, "security.audit.gwdk") + writeCLIFile(t, auditPath, `package app + +policy broken extends missing { + match "/" + deny public +} +`) + + stdout, _, err := captureCLIOutput(t, func() error { + return run([]string{"audit", "--json", "--config", config, pagePath, auditPath}) + }) + if err == nil { + t.Fatal("expected policy resolution failure") + } + var report auditReport + if err := json.Unmarshal([]byte(stdout), &report); err != nil { + t.Fatalf("expected JSON audit output, got %q: %v", stdout, err) + } + found := false + for _, finding := range report.Findings { + if finding.Code == "policy_unknown_extends" && finding.Policy == "broken" && finding.Source == auditPath+":3" { + found = true + } + } + if !found { + t.Fatalf("expected unknown extends finding with audit source, got %#v", report.Findings) + } +} + +func TestAuditCommandEmitsStandaloneAuditTests(t *testing.T) { + root := t.TempDir() + config := writeAuditCLIConfigWithSecurityHeaders(t, root) + writeCLITestModule(t, root, "example.com/gowdk-audit-emit") + pagePath := filepath.Join(root, "home.page.gwdk") + writeCLIFile(t, pagePath, `package app + +page home +route "/" + +view { +
Home
+} +`) + testPath := filepath.Join(root, "security_audit_test.go") + + _, stderr, err := captureCLIOutput(t, func() error { + return run([]string{"audit", "--config", config, "--emit-tests=" + testPath, pagePath}) + }) + if err != nil { + t.Fatalf("expected audit emit-tests to succeed: %v", err) + } + if !strings.Contains(stderr, "wrote audit tests: "+testPath) { + t.Fatalf("expected emitted test path on stderr, got %q", stderr) + } + payload, err := os.ReadFile(testPath) + if err != nil { + t.Fatal(err) + } + for _, expected := range []string{ + "package gowdkaudit_test", + `gowdktestkit "github.com/cssbruno/gowdk/runtime/testkit"`, + `Root: fstest.MapFS{`, + `SecurityHeaders: map[string]string{`, + `Name: "route serves /"`, + `Name: "security header X-Frame-Options"`, + } { + if !strings.Contains(string(payload), expected) { + t.Fatalf("expected emitted test to contain %q:\n%s", expected, payload) + } + } +} + +func TestAuditCommandRunsGeneratedAuditTests(t *testing.T) { + root := t.TempDir() + config := writeAuditCLIConfigWithSecurityHeaders(t, root) + writeCLITestModule(t, root, "example.com/gowdk-audit-run") + pagePath := filepath.Join(root, "home.page.gwdk") + writeCLIFile(t, pagePath, `package app + +page home +route "/" + +view { +
Home
+} +`) + + _, stderr, err := captureCLIOutput(t, func() error { + return run([]string{"audit", "--config", config, "--run", pagePath}) + }) + if err != nil { + t.Fatalf("expected generated audit tests to pass: %v", err) + } + if !strings.Contains(stderr, "audit tests passed:") { + t.Fatalf("expected audit test pass message, got %q", stderr) + } +} + +func TestAuditCommandReportsRuntimeAuditTestFailure(t *testing.T) { + root := t.TempDir() + config := writeMinimalCLIConfig(t, root) + writeCLITestModule(t, root, "example.com/gowdk-audit-run-fail") + pagePath := filepath.Join(root, "home.page.gwdk") + writeCLIFile(t, pagePath, `package app + +page home +route "/" + +view { +
Home
+} +`) + auditPath := filepath.Join(root, "security.audit.gwdk") + writeCLIFile(t, auditPath, `package app + +test mismatch { + expect GET "/" status 403 +} +`) + + stdout, _, err := captureCLIOutput(t, func() error { + return run([]string{"audit", "--json", "--config", config, "--run", pagePath, auditPath}) + }) + if err == nil { + t.Fatal("expected runtime audit test mismatch to fail audit") + } + var report auditReport + if err := json.Unmarshal([]byte(stdout), &report); err != nil { + t.Fatalf("expected JSON audit output, got %q: %v", stdout, err) + } + found := false + for _, finding := range report.Findings { + if finding.Code == "audit_test_failed" && finding.Target == "runtime" { + found = true + } + } + if !found { + t.Fatalf("expected audit_test_failed finding, got %#v", report.Findings) + } +} + +func writeAuditCLIConfigWithSecurityHeaders(t *testing.T, root string) string { + t.Helper() + path := filepath.Join(root, "gowdk.config.go") + writeCLIFile(t, path, `package app + +import "github.com/cssbruno/gowdk" + +var Config = gowdk.Config{ + Build: gowdk.BuildConfig{ + SecurityHeaders: gowdk.SecurityHeadersConfig{ + Enabled: true, + Headers: map[string]string{ + "X-Frame-Options": "DENY", + }, + }, + }, +} +`) + return path +} diff --git a/cmd/gowdk/main.go b/cmd/gowdk/main.go index 33417538..14aa15ce 100644 --- a/cmd/gowdk/main.go +++ b/cmd/gowdk/main.go @@ -128,7 +128,7 @@ func usage() { fmt.Println(" generate stubs [--config ] [--module ] [--ssr] [files...] write missing action/API Go handler stubs") fmt.Println(" explain [--json] explain a diagnostic code and next steps") fmt.Println(" doctor [--config ] [--module ] [--ssr] [--json] [files...] check local GOWDK environment and project health") - fmt.Println(" audit [--config ] [--module ] [--ssr] [--json] [files...] check security posture against the baseline policy") + fmt.Println(" audit [--config ] [--module ] [--ssr] [--json] [--emit-tests[=]] [--run] [files...] check security posture and optional runtime tests") fmt.Println(" contracts [--json] [dir] print Go contract registration metadata") fmt.Println(" graph [--json] [dir] print command/event contract graph") fmt.Println(" trace [--json] [dir] print one command/query/event/job contract trace") diff --git a/docs/compiler/generated-output.md b/docs/compiler/generated-output.md index a51f5268..6f5510fc 100644 --- a/docs/compiler/generated-output.md +++ b/docs/compiler/generated-output.md @@ -257,6 +257,9 @@ requests, applies `http.Server` defaults of `ReadHeaderTimeout: 5s`, `MaxHeaderBytes: 1 MiB`, maps extensionless routes to nested `index.html` files, and does not list directories. It exposes `/_gowdk/health` and adds `X-GOWDK-App`, `X-GOWDK-Module`, and `X-GOWDK-Instance-ID` headers to responses. +When `Build.SecurityHeaders.Enabled` is true, generated apps also pass the +configured header map into `runtime/app` so every response path emits those +headers. Request-time action/API/fragment dispatch registers generated backend routes with `runtime/app.BackendRouter` and passes the router hook into `runtime/app`. Generated action/API body caps default to 1 MiB and use `Build.BodyLimits` diff --git a/docs/compiler/manifest.md b/docs/compiler/manifest.md index 626ab47d..b2d59074 100644 --- a/docs/compiler/manifest.md +++ b/docs/compiler/manifest.md @@ -167,8 +167,8 @@ security posture: every route, backend endpoint, and contract with its guards, CSRF state, body limit, public/default-deny classification, and source location, plus a `frontend` surface block. Like the route and asset manifests, it is pure data — it never evaluates policy. `gowdk audit` reads this same -posture and applies the security baseline (and, in later M8 phases, declared -policies) to produce findings. +posture and applies the security baseline plus declared `*.audit.gwdk` policies +to produce findings. ```json { @@ -198,8 +198,10 @@ policies) to produce findings. } ``` -`version` is the security manifest schema version. The `frontend` block is -populated by the frontend audits in later M8 phases. +`version` is the security manifest schema version. The `frontend` block records +client-visible routes that rely on generated default-deny handling, secret-like +embedded assets or build-time values, raw `g:html` sinks, and configured +security response header names. ## Planned Manifest Work diff --git a/docs/engineering/architecture.md b/docs/engineering/architecture.md index 420a93d2..3cfe546a 100644 --- a/docs/engineering/architecture.md +++ b/docs/engineering/architecture.md @@ -145,7 +145,7 @@ manifest report (`internal/lang/testdata/manifest_golden`). | Component | Responsibility | Owner | Notes | | --- | --- | --- | --- | -| `cmd/gowdk` | CLI entrypoint. | Core | Exposes `version`, `tokens`, `fmt`, `check`, `audit`, `manifest`, `sitemap`, `routes`, `endpoints`, `inspect`, `generate stubs`, `build`, `dev`, `preview`, `serve`, and `lsp`. `build` can emit spa files, generated embedded app source, an optional binary, an optional WASM artifact, OpenAPI/AsyncAPI inspection reports, and a non-served security posture report for all discovered sources, selected configured modules, or spa `Build.Targets`; `audit` evaluates the IR-derived posture against the built-in baseline and exits non-zero on error findings; `inspect go-bindings` reports Go interop status for backend handlers, load functions, build-time Go calls, and web contracts; `generate stubs` writes conservative missing action/API handler stubs; `dev` compares input content hashes, can use incremental spa rendering for page-only plain output changes, persists a dev input cache, serves static output, or runs/restarts a generated app binary for backend/SSR flows; `preview` builds and serves a local deploy preview, with `--hot` reusing the dev loop. | +| `cmd/gowdk` | CLI entrypoint. | Core | Exposes `version`, `tokens`, `fmt`, `check`, `audit`, `manifest`, `sitemap`, `routes`, `endpoints`, `inspect`, `generate stubs`, `build`, `dev`, `preview`, `serve`, and `lsp`. `build` can emit spa files, generated embedded app source, an optional binary, an optional WASM artifact, OpenAPI/AsyncAPI inspection reports, and a non-served security posture report for all discovered sources, selected configured modules, or spa `Build.Targets`; `audit` evaluates the IR-derived posture against the built-in baseline and declared policies, emits/runs generated audit tests, and exits non-zero on error findings; `inspect go-bindings` reports Go interop status for backend handlers, load functions, build-time Go calls, and web contracts; `generate stubs` writes conservative missing action/API handler stubs; `dev` compares input content hashes, can use incremental spa rendering for page-only plain output changes, persists a dev input cache, serves static output, or runs/restarts a generated app binary for backend/SSR flows; `preview` builds and serves a local deploy preview, with `--hot` reusing the dev loop. | | `gowdk` root package | Public config, render modes, addon registration, and extension contracts. | Core | Includes `Config`, `RenderMode`, `Addon`, `CSSConfig`, `CSSProcessor`, and `GoBlockConsumer`. | | `internal/discover` | Find portable `.gwdk` files from include/exclude patterns. | Compiler | Recursive glob discovery implemented. | | `internal/gwdkast` | Define the typed GOWDK source AST. | Compiler | Package declarations, typed page/component/layout/route/render/layout/guard/CSS declarations, component CSS scope/hash metadata, metadata declarations, Go imports, GOWDK uses, stores, typed component contracts, blocks, endpoint declarations, parsed view nodes, literal records, and source spans implemented. | @@ -159,10 +159,10 @@ manifest report (`internal/lang/testdata/manifest_golden`). | `internal/lsp` | Language Server Protocol bridge for diagnostics, formatting, completions, and hover. | Tools | Dependency-free stdio server implemented with baseline and open-project completions plus hover for known language tokens and open-project symbols. | | `internal/project` | Load project-level config, module source groups, build targets, and future source roots. | Compiler | SPA `gowdk.config.go` subset implemented for build discovery, output, and `Build.Targets`; project-level CLI commands require this config or an explicit `--config` file before compiling `.gwdk` code. | | `internal/compiler` | Validate manifests and coordinate compilation metadata. | Compiler | Render-mode, duplicate identity, redundant component implementation, component Go contract, saved default `go {}` package type-checking with sibling Go files, route shape, duplicate route param, duplicate route pattern, route-method, required page-view validation, default `go {}` backend endpoint binding fallback, and `go/packages`-backed backend binding implemented. CLI route/endpoint reports now convert through `internal/gwdkir.Program`. | -| `internal/securitymanifest` | Project compiler IR into declarative security posture. | Tools | Builds `gowdk-security.json` posture records for routes, backend endpoints, command/query web contract endpoints, contract metadata, guards, CSRF state, body limits, public/default-deny classification, and source locations. It describes posture only; policy evaluation lives in `internal/auditspec`. | -| `internal/auditspec` | Evaluate security posture against audit policy. | Tools | Provides the policy model, selector matcher, `extends` composition, built-in baseline, and registry-backed findings for `gowdk audit`. The first baseline covers action/command CSRF, guardless action/fragment/command/query endpoints, and public-by-omission APIs; frontend audits and declared `*.audit.gwdk` policies remain planned. | +| `internal/securitymanifest` | Project compiler IR into declarative security posture. | Tools | Builds `gowdk-security.json` posture records for routes, backend endpoints, command/query web contract endpoints, contract metadata, guards, CSRF state, body limits, public/default-deny classification, source locations, frontend bundle-secret candidates, raw-HTML sinks, unguarded client-visible routes, and configured security header names. It describes posture only; policy evaluation lives in `internal/auditspec`. | +| `internal/auditspec` | Evaluate security posture against audit policy. | Tools | Provides the policy model, selector matcher, `extends` composition, built-in baseline, declared `*.audit.gwdk` policy lowering, frontend audit rules, and registry-backed findings for `gowdk audit`. | | `internal/buildgen` | Emit route-derived spa HTML files for build-time pages and SSR render artifacts. | Compiler | Disk builds, memory builds, incremental SPA builds, and SSR artifact planning consume `internal/gwdkir.Program`. Initial simple page, literal build data, imported Go build data calls, literal dynamic path expansion, component expansion, partial runtime asset emission, default JS island asset emission, component-level non-CSS asset emission, component-level WASM island asset emission, page-level `go client {}` WASM mount asset emission, concrete and dynamic SSR page rendering with declared `load {}` placeholders, route manifest emission, asset manifest emission, OpenAPI report emission, non-served security posture report emission, mandatory build report emission with cache-policy and request-time skip events, identical-output write skipping, and incremental changed-page spa rendering implemented. | -| `internal/appgen` | Emit generated Go app source for embedded spa output and request-time routes. | Compiler | Auto route planning consumes `internal/gwdkir.Program`, backend adapter planning uses typed appgen IR, and generated app Go files are assembled with `go/ast`/`go/printer` before `go/format`. Generates `go.mod`, `main.go`, copied spa assets, thin `runtime/app` server wiring, `runtime/app.BackendRouter` registrations for feature-bound action/API/fragment routes, 501 stubs for missing/unsupported handlers, POST redirect and partial fragment action handlers backed by `runtime/form`, `runtime/response`, `runtime/validation`, and `addons/partial`, form input decoders, concrete and dynamic standalone fragment routes, concrete and dynamic SSR route handlers backed by `runtime/route`, declared SSR load path calls with redirect/error-page handling through `addons/ssr`, shared request-time guard checks through `runtime/guard`, generated `gowdk_go/` packages for default `go {}` and `go ssr {}` blocks, addon `GoBlockConsumer` Go files, split backend apps, command/query contract exposure metadata in adapter IR including runtime roles, identical-output write skipping, stale embedded spa cleanup, and can invoke `go build` for local binaries or Go `js/wasm` artifacts. | +| `internal/appgen` | Emit generated Go app source for embedded spa output and request-time routes. | Compiler | Auto route planning consumes `internal/gwdkir.Program`, backend adapter planning uses typed appgen IR, and generated app Go files are assembled with `go/ast`/`go/printer` before `go/format`. Generates `go.mod`, `main.go`, copied spa assets, thin `runtime/app` server wiring, configured runtime security headers, generated audit `_test.go` files, `runtime/app.BackendRouter` registrations for feature-bound action/API/fragment routes, 501 stubs for missing/unsupported handlers, POST redirect and partial fragment action handlers backed by `runtime/form`, `runtime/response`, `runtime/validation`, and `addons/partial`, form input decoders, concrete and dynamic standalone fragment routes, concrete and dynamic SSR route handlers backed by `runtime/route`, declared SSR load path calls with redirect/error-page handling through `addons/ssr`, shared request-time guard checks through `runtime/guard`, generated `gowdk_go/` packages for default `go {}` and `go ssr {}` blocks, addon `GoBlockConsumer` Go files, split backend apps, command/query contract exposure metadata in adapter IR including runtime roles, identical-output write skipping, stale embedded spa cleanup, and can invoke `go build` for local binaries or Go `js/wasm` artifacts. | | `internal/clientrt` | Emit client runtime for partial updates and static-first SPA navigation. | Runtime | First partial form enhancement runtime emits lifecycle hooks, target/swap request headers, swaps, focus restoration, loading state metadata, island remounts, and page-level `go client {}` remounts after SPA navigation. | | `runtime/render` | Core rendering engine used by static output, actions, partials, and SSR. | Runtime | Renderer and generated-code builder implemented; expression text writes escape by default. | | `runtime/component` | Generated component runtime contract. | Runtime | Initial component interface implemented. | @@ -174,7 +174,8 @@ manifest report (`internal/lang/testdata/manifest_golden`). | `runtime/response` | HTML, redirect, fragment, and JSON response envelopes. | Runtime | Initial response model implemented. | | `runtime/asset` | Asset manifest resolution. | Runtime | Initial manifest helper implemented. | | `runtime/route` | Runtime route matching for generated request-time routes. | Runtime | Dynamic route matcher for first-slice generated SSR and standalone fragment routes implemented. | -| `runtime/app` | Shared generated app HTTP server. | Runtime | Serves embedded spa files, identity headers, health checks, asset manifest counts, optional generated 404/500 pages, no-JS cookie acknowledgement, server-side cookie notice hiding, generated CSRF token injection for POST forms, request-time panic boundaries, and generated action/API/fragment/SSR callback hooks. | +| `runtime/app` | Shared generated app HTTP server. | Runtime | Serves embedded spa files, configured security headers, identity headers, health checks, asset manifest counts, optional generated 404/500 pages, no-JS cookie acknowledgement, server-side cookie notice hiding, generated CSRF token injection for POST forms, request-time panic boundaries, and generated action/API/fragment/SSR callback hooks. | +| `runtime/testkit` | Generated audit test helpers. | Runtime | Provides small `httptest` helpers used by generated `gowdk_audit_test.go` files and `gowdk audit --run` to verify route status, method rejection, and configured response headers in-process. | | `runtime/contracts` | Typed contract registry and in-process dispatch. | Runtime | First runtime slice implemented for queries, commands, backend-owned domain and integration events, presentation events, jobs, metadata, stable observation names and labels for logs/metrics/traces, local command-buffered event dispatch, event-envelope capture/replay with stable IDs, dependency-free outbox/broker/presentation-fanout/event-source/seen-store interfaces, command event sinks, an event worker loop with ack/nack plus context cancellation and optional post-ack deduplication windows, a dependency-free file outbox adapter, dependency-free in-memory broker/EventSource adapter, dependency-free in-memory and file-backed seen stores, and dependency-free SSE presentation fanout adapter. Concrete Redis Streams, Redis TTL seen-store, NATS, and WebSocket adapters are nested optional modules. Split worker/cron generation, retry backoff policy, and managed deployment recipes remain planned. | | `addons/static` | Build-time static page output. | Addon | Capability boundary implemented; build-time output uses `runtime/render` through the compiler view renderer. | | `addons/spa` | Static-first SPA navigation compatibility surface. | Addon | Keeps the existing SPA feature package and aliases build-time route output types from `addons/static`. | diff --git a/docs/engineering/security.md b/docs/engineering/security.md index 28170498..a7b716cd 100644 --- a/docs/engineering/security.md +++ b/docs/engineering/security.md @@ -63,9 +63,10 @@ and remediation; run `gowdk explain ` for details. `gowdk audit` is a standalone command. `gowdk build` never runs it, so it cannot fail a build implicitly; run it on demand or in CI, where its non-zero exit on error findings gates the pipeline. It is the auditable, human- and -LLM-readable view of how close generated output is to these gates. Later M8 -phases add frontend audits, declared `*.audit.gwdk` policies, and an -integration-test runner. +LLM-readable view of how close generated output is to these gates. The audit +also reads declared `*.audit.gwdk` policies, checks frontend risks such as +bundle secrets and raw-HTML sinks, and can emit or run readable runtime tests +with `gowdk audit --emit-tests` and `gowdk audit --run`. ## Security Review Triggers diff --git a/docs/language/grammar.md b/docs/language/grammar.md index 265472b5..be9643dd 100644 --- a/docs/language/grammar.md +++ b/docs/language/grammar.md @@ -27,6 +27,25 @@ ident = letterOrUnderscore (letter | digit | "_")* blockName = letterOrUnderscore (letter | digit | "_" | "." | "-")* ``` +Audit policy files use the `*.audit.gwdk` suffix and a separate top-level +grammar: + +```text +auditFile = (blank | comment | packageDecl | policyDecl | testDecl)* +policyDecl = "policy" whitespace+ ident (whitespace+ "extends" whitespace+ ident ("," whitespace* ident)*)? whitespace* "{" +policyLine = applyLine | requireLine | denyLine | allowLine +applyLine = ("match" | "apply" whitespace+ "to") whitespace+ string +requireLine = "require" whitespace+ ("csrf" | "guard" whitespace+ value | "header" whitespace+ string | "max_body" whitespace+ value | "no_secrets_in_bundle") (whitespace+ "as" whitespace+ value)? +denyLine = "deny" whitespace+ ("public" | "raw_html") (whitespace+ "as" whitespace+ value)? +allowLine = "allow" whitespace+ "raw_html" whitespace+ value +testDecl = "test" whitespace+ ident whitespace* "{" +testLine = "expect" whitespace+ method whitespace+ string (whitespace+ "as" whitespace+ string)? whitespace+ "status" whitespace+ statusCode + | "expect" whitespace+ "header" whitespace+ string whitespace+ string +value = ident | string +method = "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE" +statusCode = digit digit digit +``` + The parser currently scans each trimmed line independently. It records declarations and captures raw body text for `paths {}`, `build {}`, `load {}`, `go {}`, `go target {}`, `view {}`, and `style {}` blocks until their diff --git a/docs/product/requirements.md b/docs/product/requirements.md index fdcd49ef..2191974c 100644 --- a/docs/product/requirements.md +++ b/docs/product/requirements.md @@ -51,7 +51,7 @@ language references, compiler docs, and examples. | PRD-023 | Keep current documentation aligned with implemented CLI, config, compiler, language, routing, deployment, and examples. | High | Implemented | `README.md`, `docs/getting-started.md`, reference docs, language docs, compiler docs, and `examples/README.md` describe current support and call out planned behavior. | | PRD-024 | Require project config before compiling or validating `.gwdk` code. | High | Implemented | `check`, `audit`, `manifest`, `sitemap`, `routes`, `build`, and `dev` require `gowdk.config.go` in the current directory or an explicit `--config `, even when explicit `.gwdk` file paths are provided. | | PRD-025 | Keep framework integrations optional and outside compiler/runtime core. | Medium | Implemented | Generated apps expose standard `net/http` handlers and framework-neutral code by default. Optional `runtime/adapters/echo`, `runtime/adapters/gin`, and `runtime/adapters/fiber` nested modules wrap the same generated `http.Handler`; docs cover Echo v5, Gin, Fiber, and Fiber adaptor caveats. | -| PRD-026 | Provide declarative security posture and baseline audit gating. | High | Partial | `internal/securitymanifest` projects validated IR into a route/endpoint/contract posture, `internal/auditspec` evaluates the built-in baseline, `gowdk audit` reports human/JSON findings with registry-backed severities and exits non-zero on error findings, and `gowdk build` writes `gowdk-security.json` to a non-served report path outside selected output. The baseline covers action/command CSRF, guardless action/fragment/command/query endpoints, and public-by-omission APIs; frontend audits, declared `*.audit.gwdk` policies, emitted integration tests, and runtime header verification remain planned. | +| PRD-026 | Provide declarative security posture and baseline audit gating. | High | Partial | `internal/securitymanifest` projects validated IR into a route/endpoint/contract/frontend posture, `internal/auditspec` evaluates the built-in baseline plus declared `*.audit.gwdk` policies, `gowdk audit` reports human/JSON findings with registry-backed severities and exits non-zero on error findings, and `gowdk build` writes `gowdk-security.json` to a non-served report path outside selected output. The baseline covers action/command CSRF, guardless action/fragment/command/query endpoints, public-by-omission APIs, bundle secret leaks, client-visible guardless routes, and raw-HTML sinks; policy rules can require headers. `gowdk audit --emit-tests` writes readable `_test.go` posture tests and `--run` executes them through `runtime/testkit`; broader auth/session ownership, role fixture injection, and deeper browser/data-flow analysis remain planned or app-owned. | ## P0/P1/P2 Decision Backlog diff --git a/docs/product/roadmap.md b/docs/product/roadmap.md index 34cbe1cc..1a448bdb 100644 --- a/docs/product/roadmap.md +++ b/docs/product/roadmap.md @@ -133,9 +133,10 @@ level, the current baseline already includes: standalone fragment routes, and concrete or dynamic request-time SSR pages with declared `load {}` fields through buildgen, appgen, `runtime/app`, and `runtime/route`. -- first-slice `gowdk audit` posture and baseline policy evaluation for - routes, backend endpoints, and command/query contract web endpoints, with - registry-backed findings and CI-friendly JSON output. +- `gowdk audit` posture and policy evaluation for routes, backend endpoints, + command/query contract web endpoints, and frontend audit surfaces, with + declared `*.audit.gwdk` policies, generated runtime audit tests, + registry-backed findings, and CI-friendly JSON output. Do not roadmap those completed slices as future work. Future work should stabilize their contracts, remove generation debt, and fill the missing diff --git a/docs/product/security-audit-spec.md b/docs/product/security-audit-spec.md index 5ef3be7f..495de56a 100644 --- a/docs/product/security-audit-spec.md +++ b/docs/product/security-audit-spec.md @@ -70,9 +70,9 @@ only from the diagnostic registry, so the baseline never hardcodes severity. - [x] `gowdk audit` applies the baseline, cites findings by code + `file:line`, and exits non-zero on error findings. - [x] New `audit_*` / `policy_*` codes are registered and `gowdk explain`-able. -- [ ] Frontend audits (secret leak, route-guard coverage, headers/CSP, raw-HTML). -- [ ] `*.audit.gwdk` parser → IR → composable policy engine (`extends`, selectors). -- [ ] `test {}` blocks → generated `httptest` tests; `gowdk audit --emit-tests` +- [x] Frontend audits (secret leak, route-guard coverage, headers/CSP, raw-HTML). +- [x] `*.audit.gwdk` parser → IR → composable policy engine (`extends`, selectors). +- [x] `test {}` blocks → generated `httptest` tests; `gowdk audit --emit-tests` and `--run`; `runtime/app` security-header capability. ## Delivery Phases @@ -82,9 +82,9 @@ only from the diagnostic registry, so the baseline never hardcodes severity. `internal/securitymanifest` (IR → posture); `gowdk audit` with the baseline; `gowdk-security.json` at build time outside the served output directory. Unit + CLI tests. -- **Phase 2:** the four frontend audits as baseline rules. -- **Phase 3:** the `*.audit.gwdk` file kind and declared composable policies. -- **Phase 4:** `runtime/testkit`, generated `_test.go`, `--emit-tests`/`--run`, +- **Phase 2 (shipped):** the four frontend audits as baseline rules. +- **Phase 3 (shipped):** the `*.audit.gwdk` file kind and declared composable policies. +- **Phase 4 (shipped):** `runtime/testkit`, generated `_test.go`, `--emit-tests`/`--run`, and the `runtime/app` security-header capability. ## Issue Alignment @@ -97,8 +97,9 @@ from IR metadata), and diagnostics issues #328/#255/#107/#109. ```sh go build ./cmd/gowdk -go test ./internal/securitymanifest ./internal/auditspec ./cmd/gowdk ./internal/diagnostics +go test ./internal/securitymanifest ./internal/auditspec ./internal/parser ./internal/lang ./internal/appgen ./runtime/app ./runtime/testkit ./cmd/gowdk ./internal/diagnostics go run ./cmd/gowdk audit --json --config gowdk.config.go +go run ./cmd/gowdk audit --emit-tests --run --config gowdk.config.go go run ./cmd/gowdk explain audit_api_public_by_omission go run ./cmd/gowdk build --out /tmp/gowdk-build examples/pages/home.page.gwdk \ examples/pages/hero.cmp.gwdk && test -f /tmp/.gowdk/reports/gowdk-build/gowdk-security.json diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 8f35b676..f8b2abf6 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -23,7 +23,7 @@ gowdk inspect ir|tree|endpoint-graph|go-bindings [--config ] [--module ] [--module ] [--ssr] [files...] gowdk explain [--json] gowdk doctor [--config ] [--module ] [--ssr] [--json] [files...] -gowdk audit [--config ] [--module ] [--ssr] [--json] [files...] +gowdk audit [--config ] [--module ] [--ssr] [--json] [--emit-tests[=]] [--run] [files...] gowdk contracts [--json] [dir] gowdk graph [--json] [dir] gowdk trace [--json] [dir] @@ -57,6 +57,10 @@ gowdk lsp [--ssr] It exits non-zero when any error-severity finding exists, so it can gate CI. `--json` prints the posture manifest plus findings and a summary. Every finding carries a diagnostic code; run `gowdk explain ` for details. + `--emit-tests` writes a readable `gowdk_audit_test.go` file (or the path from + `--emit-tests=`) that drives `runtime/app` through `runtime/testkit`. + `--run` generates and executes the same audit test source with `go test`; a + failed expectation is reported as `audit_test_failed`. `gowdk build` also writes the posture alone to a non-served `.gowdk/reports//gowdk-security.json` path outside the selected output directory. @@ -114,6 +118,7 @@ go run ./cmd/gowdk explain missing_ssr_addon go run ./cmd/gowdk explain --json spa_dynamic_route_missing_paths go run ./cmd/gowdk audit --config gowdk.config.go go run ./cmd/gowdk audit --json --ssr --config gowdk.config.go +go run ./cmd/gowdk audit --emit-tests --run --config gowdk.config.go go run ./cmd/gowdk doctor go run ./cmd/gowdk doctor --json go run ./cmd/gowdk doctor --module frontend --ssr @@ -181,7 +186,7 @@ generated outputs. The target's intermediate build output is inferred as `--force` is passed. The `minimal` template skips the starter component and writes only the config, `.gitignore`, one page, and one CSS file. -`check`, `manifest`, `sitemap`, `routes`, `build`, and `dev` require a config +`check`, `audit`, `manifest`, `sitemap`, `routes`, `build`, and `dev` require a config file before they compile or validate `.gwdk` code. By default they load `gowdk.config.go` from the current directory; `--config ` can point at a different config for project examples or one-off checks. @@ -238,6 +243,10 @@ so it cannot fail a build implicitly; run it on demand or wire it into CI, where its non-zero exit on error findings gates the pipeline. The posture alone is also emitted as `gowdk-security.json` by `gowdk build`, but outside the selected output directory in a non-served `.gowdk/reports//` path. +Declared `*.audit.gwdk` policies are discovered with the rest of the source +set. `--emit-tests` writes a committable standalone `_test.go`; `--run` writes +a temporary `_test.go`, executes `go test` from the project root, and folds +failures back into the audit report. `--wasm` produces a Go `js/wasm` compile artifact from the generated app. This is a deploy artifact for hosts that can run Go WebAssembly; it is separate from diff --git a/docs/reference/config.md b/docs/reference/config.md index b723d4bc..25662d2f 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -291,12 +291,12 @@ runtime-specific security controls. ## Build `BuildConfig.Output`, `BuildConfig.Mode`, `BuildConfig.Assets`, -`BuildConfig.Head`, `BuildConfig.CSRF`, `BuildConfig.BodyLimits`, +`BuildConfig.Head`, `BuildConfig.CSRF`, `BuildConfig.SecurityHeaders`, `BuildConfig.BodyLimits`, `BuildConfig.AllowMissingBackend`, `BuildConfig.Stylesheets`, `BuildConfig.Scripts`, and `BuildConfig.Targets` are target build settings. Current `gowdk build` reads literal `Build.Output`, `Build.Mode`, -`Build.Head`, `Build.CSRF`, `Build.BodyLimits`, `Build.AllowMissingBackend`, -`Build.Stylesheets`, `Build.Scripts`, and `Build.Targets` from +`Build.Head`, `Build.CSRF`, `Build.SecurityHeaders`, `Build.BodyLimits`, +`Build.AllowMissingBackend`, `Build.Stylesheets`, `Build.Scripts`, and `Build.Targets` from `gowdk.config.go`; `--out` overrides `Build.Output` for ad hoc builds. `BuildConfig.Assets` remains planned. @@ -309,6 +309,7 @@ type BuildConfig struct { Assets gowdk.AssetMode Head gowdk.HeadConfig CSRF gowdk.CSRFConfig + SecurityHeaders gowdk.SecurityHeadersConfig BodyLimits gowdk.BodyLimitsConfig AllowMissingBackend bool Stylesheets []gowdk.Stylesheet @@ -332,6 +333,11 @@ type CSRFConfig struct { Insecure bool } +type SecurityHeadersConfig struct { + Enabled bool + Headers map[string]string +} + type BodyLimitsConfig struct { ActionBytes int64 APIBytes int64 @@ -387,6 +393,14 @@ flag, uses the default cookie name `gowdk-csrf` instead of `__Host-gowdk-csrf`, and rejects explicit `__Host-`/`__Secure-` cookie names because browsers require those prefixes to be Secure. +`SecurityHeaders` controls additional headers written by generated app +handlers. When `Enabled` is true, each entry in `Headers` is passed to +`runtime/app` and emitted on every generated response path, including health +checks and generated errors. Use it for app-owned headers such as +`X-Content-Type-Options`, `Referrer-Policy`, `Content-Security-Policy`, and +`X-Frame-Options`. Keep TLS-boundary headers such as `Strict-Transport-Security` +at the HTTPS edge unless the generated app is directly responsible for TLS. + `BodyLimits` controls generated request body caps in bytes. Omitted or non-positive values use the default 1 MiB cap. `ActionBytes` applies to generated action POST handlers and web command form adapters before form diff --git a/docs/reference/deployment.md b/docs/reference/deployment.md index 4f8e7306..5b605f0e 100644 --- a/docs/reference/deployment.md +++ b/docs/reference/deployment.md @@ -183,9 +183,10 @@ on forwarded IP, host, or scheme values. ## Security Headers -Generated binaries do not currently install a global security-header middleware. -Set edge security headers in the reverse proxy or app-owned middleware before -making request-time routes public: +Generated binaries can emit configured response headers through +`Build.SecurityHeaders`. Edge or app-owned middleware can still add deployment +headers that belong at the proxy/TLS boundary. Before making request-time routes +public, configure the generated app, reverse proxy, or app middleware for: - `X-Content-Type-Options: nosniff`. - `Referrer-Policy: strict-origin-when-cross-origin` for most sites, or diff --git a/docs/reference/diagnostic-codes.md b/docs/reference/diagnostic-codes.md index 89d42ac1..4f0486e1 100644 --- a/docs/reference/diagnostic-codes.md +++ b/docs/reference/diagnostic-codes.md @@ -166,8 +166,8 @@ Parser diagnostics emit stable codes for common unsupported syntax and keep `audit_runtime_mismatch`, `audit_test_failed`, `policy_duplicate_name`, `policy_extends_cycle`, `policy_unknown_extends`, `policy_unknown_selector`, `policy_selector_matched_nothing`. These are - experimental; some are emitted by later M8 phases (frontend audits, - declared policies, and the `--run` test runner). + experimental and emitted by `gowdk audit`, declared audit policies, or the + optional runtime audit test runner. ## Adding A Code diff --git a/docs/reference/testing.md b/docs/reference/testing.md index 731e584d..334164e4 100644 --- a/docs/reference/testing.md +++ b/docs/reference/testing.md @@ -18,6 +18,13 @@ GOWDK_BIN=/path/to/gowdk go test ./tests `gowdk build --out ` from the project root and asserts that `index.html` exists. +## Audit Tests + +Use `gowdk audit --emit-tests` to write a readable `gowdk_audit_test.go` file +that drives `runtime/app` through `runtime/testkit`. Use +`gowdk audit --run` in CI when runtime behavior should gate the audit report; +failed expectations are reported as `audit_test_failed`. + ## Browser Smoke For generated apps, keep the first browser smoke test narrow: diff --git a/examples/README.md b/examples/README.md index ce5b1c51..9f18d935 100644 --- a/examples/README.md +++ b/examples/README.md @@ -28,6 +28,7 @@ even when explicit `.gwdk` files are passed. | `ssr/dashboard.page.gwdk` | SSR page with `load` and guard metadata. | `go run ./cmd/gowdk check --ssr examples/ssr/dashboard.page.gwdk` | | `go-interop/imported-build.page.gwdk` | Buildable `.gwdk` import example that calls Go code from `build {}`. | `go run ./cmd/gowdk build --out /tmp/gowdk-go-interop examples/go-interop/imported-build.page.gwdk` | | `contracts/patients.page.gwdk` | Local command/query contract web adapter example backed by normal Go registrations. | `go run ./cmd/gowdk build --config examples/contracts/gowdk.config.go --out /tmp/gowdk-contracts-build --app /tmp/gowdk-contracts-app --bin /tmp/gowdk-contracts-site examples/contracts/patients.page.gwdk` | +| `security/security.audit.gwdk` | Declared audit policy example that extends the frontend baseline and includes a generated header expectation. | `go run ./cmd/gowdk check examples/security/security.audit.gwdk` | | `embed/site.page.gwdk` | Standalone one-binary generated app example. | `go run ./cmd/gowdk build --out /tmp/gowdk-embed-build --app /tmp/gowdk-embed-app --bin /tmp/gowdk-embed-site examples/embed/site.page.gwdk` | | `css/styled.page.gwdk` | Configured stylesheet-link example. | `go run ./cmd/gowdk build --config examples/css/gowdk.config.go --out /tmp/gowdk-css-build examples/css/styled.page.gwdk` | | `tailwind/site.page.gwdk` | Tailwind v4 addon example using the standalone CLI. | `go run ./cmd/gowdk build --config examples/tailwind/gowdk.config.go --out /tmp/gowdk-tailwind-build examples/tailwind/site.page.gwdk` | @@ -38,10 +39,10 @@ even when explicit `.gwdk` files are passed. Check all current examples with SSR validation enabled: ```sh -go run ./cmd/gowdk check --ssr examples/pages/*.gwdk examples/actions/*.gwdk examples/partials/*.gwdk examples/api/*.gwdk examples/ssr/*.gwdk examples/go-interop/*.gwdk examples/components/base/*.gwdk examples/components/css/*.gwdk examples/components/assets/*.gwdk examples/embed/*.gwdk examples/css/*.gwdk examples/tailwind/*.gwdk examples/contracts/*.gwdk -go run ./cmd/gowdk manifest --ssr examples/pages/*.gwdk examples/actions/*.gwdk examples/partials/*.gwdk examples/api/*.gwdk examples/ssr/*.gwdk examples/go-interop/*.gwdk examples/components/base/*.gwdk examples/components/css/*.gwdk examples/components/assets/*.gwdk examples/embed/*.gwdk examples/css/*.gwdk examples/tailwind/*.gwdk examples/contracts/*.gwdk -go run ./cmd/gowdk sitemap --ssr examples/pages/*.gwdk examples/actions/*.gwdk examples/partials/*.gwdk examples/api/*.gwdk examples/ssr/*.gwdk examples/go-interop/*.gwdk examples/components/base/*.gwdk examples/components/css/*.gwdk examples/components/assets/*.gwdk examples/embed/*.gwdk examples/css/*.gwdk examples/tailwind/*.gwdk examples/contracts/*.gwdk -go run ./cmd/gowdk routes --ssr examples/pages/*.gwdk examples/actions/*.gwdk examples/partials/*.gwdk examples/api/*.gwdk examples/ssr/*.gwdk examples/go-interop/*.gwdk examples/components/base/*.gwdk examples/components/css/*.gwdk examples/components/assets/*.gwdk examples/embed/*.gwdk examples/css/*.gwdk examples/tailwind/*.gwdk examples/contracts/*.gwdk +go run ./cmd/gowdk check --ssr examples/pages/*.gwdk examples/actions/*.gwdk examples/partials/*.gwdk examples/api/*.gwdk examples/ssr/*.gwdk examples/go-interop/*.gwdk examples/components/base/*.gwdk examples/components/css/*.gwdk examples/components/assets/*.gwdk examples/embed/*.gwdk examples/css/*.gwdk examples/tailwind/*.gwdk examples/contracts/*.gwdk examples/security/*.gwdk +go run ./cmd/gowdk manifest --ssr examples/pages/*.gwdk examples/actions/*.gwdk examples/partials/*.gwdk examples/api/*.gwdk examples/ssr/*.gwdk examples/go-interop/*.gwdk examples/components/base/*.gwdk examples/components/css/*.gwdk examples/components/assets/*.gwdk examples/embed/*.gwdk examples/css/*.gwdk examples/tailwind/*.gwdk examples/contracts/*.gwdk examples/security/*.gwdk +go run ./cmd/gowdk sitemap --ssr examples/pages/*.gwdk examples/actions/*.gwdk examples/partials/*.gwdk examples/api/*.gwdk examples/ssr/*.gwdk examples/go-interop/*.gwdk examples/components/base/*.gwdk examples/components/css/*.gwdk examples/components/assets/*.gwdk examples/embed/*.gwdk examples/css/*.gwdk examples/tailwind/*.gwdk examples/contracts/*.gwdk examples/security/*.gwdk +go run ./cmd/gowdk routes --ssr examples/pages/*.gwdk examples/actions/*.gwdk examples/partials/*.gwdk examples/api/*.gwdk examples/ssr/*.gwdk examples/go-interop/*.gwdk examples/components/base/*.gwdk examples/components/css/*.gwdk examples/components/assets/*.gwdk examples/embed/*.gwdk examples/css/*.gwdk examples/tailwind/*.gwdk examples/contracts/*.gwdk examples/security/*.gwdk ``` Build the current simple page: diff --git a/examples/security/security.audit.gwdk b/examples/security/security.audit.gwdk new file mode 100644 index 00000000..470f53df --- /dev/null +++ b/examples/security/security.audit.gwdk @@ -0,0 +1,12 @@ +package security + +policy browser_hardening extends "baseline.frontend" { + match "frontend" + require header "X-Frame-Options" + require header "Content-Security-Policy" + deny raw_html +} + +test headers { + expect header "X-Frame-Options" "DENY" +} diff --git a/gowdk.go b/gowdk.go index 36353c15..a39a1917 100644 --- a/gowdk.go +++ b/gowdk.go @@ -163,6 +163,7 @@ type BuildConfig struct { Assets AssetMode Head HeadConfig CSRF CSRFConfig + SecurityHeaders SecurityHeadersConfig BodyLimits BodyLimitsConfig AllowMissingBackend bool Stylesheets []Stylesheet @@ -179,6 +180,14 @@ type HeadConfig struct { TwitterCard string } +// SecurityHeadersConfig declares generated runtime response headers. Audit +// policy can require these headers statically, and generated audit tests can +// verify that the handler emits them. +type SecurityHeadersConfig struct { + Enabled bool + Headers map[string]string +} + const DefaultCSRFSecretEnv = "GOWDK_CSRF_SECRET" // CSRFConfig controls generated action CSRF token wiring. diff --git a/internal/appgen/appgen.go b/internal/appgen/appgen.go index 5046db5f..543f618f 100644 --- a/internal/appgen/appgen.go +++ b/internal/appgen/appgen.go @@ -14,6 +14,7 @@ const ( serverDirName = "cmd/server" appOutputDirName = appPackageDirName + "/app" appFileName = appPackageDirName + "/app.go" + auditTestFileName = appPackageDirName + "/gowdk_audit_test.go" mainFileName = serverDirName + "/main.go" modFileName = "go.mod" ) @@ -99,6 +100,18 @@ func GenerateWithOptions(outputDir, appDir string, options Options) (Result, err if err := writeFileIfChanged(filepath.Join(absApp, appFileName), appSource); err != nil { return Result{}, err } + auditTestSource, err := GeneratedAuditTestSource(options) + if err != nil { + return Result{}, err + } + auditTestPath := filepath.Join(absApp, auditTestFileName) + if len(auditTestSource) > 0 { + if err := writeFileIfChanged(auditTestPath, auditTestSource); err != nil { + return Result{}, err + } + } else if err := os.Remove(auditTestPath); err != nil && !os.IsNotExist(err) { + return Result{}, err + } scriptFiles, err := writeInlineGoBlockFiles(absApp, options) if err != nil { return Result{}, err diff --git a/internal/appgen/appgen_test.go b/internal/appgen/appgen_test.go index 30eca489..09c62017 100644 --- a/internal/appgen/appgen_test.go +++ b/internal/appgen/appgen_test.go @@ -111,6 +111,100 @@ func TestGenerateWritesEmbeddedSPAApp(t *testing.T) { } } +func TestGenerateWiresSecurityHeadersWhenConfigured(t *testing.T) { + root := t.TempDir() + outputDir := filepath.Join(root, "dist") + appDir := filepath.Join(root, "generated-app") + writeTestFile(t, filepath.Join(outputDir, "index.html"), "
Home
") + + result, err := GenerateWithOptions(outputDir, appDir, Options{ + Config: gowdk.Config{ + Build: gowdk.BuildConfig{ + SecurityHeaders: gowdk.SecurityHeadersConfig{ + Enabled: true, + Headers: map[string]string{ + "Content-Security-Policy": "default-src 'self'", + "X-Frame-Options": "DENY", + }, + }, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + payload, err := os.ReadFile(result.PackagePath) + if err != nil { + t.Fatal(err) + } + for _, expected := range []string{ + `SecurityHeaders: map[string]string{`, + `"Content-Security-Policy": "default-src 'self'",`, + `"X-Frame-Options": "DENY"`, + } { + if !strings.Contains(string(payload), expected) { + t.Fatalf("expected generated app to contain %q:\n%s", expected, payload) + } + } +} + +func TestGenerateWritesAuditIntegrationTest(t *testing.T) { + root := t.TempDir() + outputDir := filepath.Join(root, "dist") + appDir := filepath.Join(root, "generated-app") + writeTestFile(t, filepath.Join(outputDir, "index.html"), "
Home
") + + result, err := GenerateWithOptions(outputDir, appDir, Options{ + Config: gowdk.Config{ + Build: gowdk.BuildConfig{ + SecurityHeaders: gowdk.SecurityHeadersConfig{ + Enabled: true, + Headers: map[string]string{"X-Frame-Options": "DENY"}, + }, + }, + }, + IR: &gwdkir.Program{ + Routes: []gwdkir.Route{{ + Kind: gwdkir.RouteSPA, + Method: "GET", + Path: "/", + PageID: "home", + Render: gowdk.SPA, + Guards: []string{"public"}, + }}, + AuditSpecs: []gwdkir.AuditSpec{{ + Source: "security.audit.gwdk", + Tests: []gwdkir.AuditTest{{ + Name: "home", + Body: `expect GET "/" status 200`, + }}, + }}, + }, + }) + if err != nil { + t.Fatal(err) + } + payload, err := os.ReadFile(filepath.Join(result.AppDir, auditTestFileName)) + if err != nil { + t.Fatal(err) + } + for _, expected := range []string{ + "package gowdkapp", + "func TestGOWDKAuditGeneratedSecurityPosture(t *testing.T)", + "handler, err := Handler()", + `Name: "route serves /"`, + `WantStatus: http.StatusOK`, + `Name: "security header X-Frame-Options"`, + `WantHeader: map[string]string{`, + `"X-Frame-Options": "DENY"`, + `Name: "home GET /"`, + } { + if !strings.Contains(string(payload), expected) { + t.Fatalf("expected generated audit test to contain %q:\n%s", expected, payload) + } + } +} + func TestGeneratePreservesUnchangedFilesAndRemovesStaleSPAFiles(t *testing.T) { root := t.TempDir() outputDir := filepath.Join(root, "dist") diff --git a/internal/appgen/audit_tests.go b/internal/appgen/audit_tests.go new file mode 100644 index 00000000..8239b8f0 --- /dev/null +++ b/internal/appgen/audit_tests.go @@ -0,0 +1,399 @@ +package appgen + +import ( + "fmt" + "go/format" + "net/http" + "path" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/cssbruno/gowdk" + "github.com/cssbruno/gowdk/internal/gwdkir" + "github.com/cssbruno/gowdk/internal/securitymanifest" +) + +type auditTestMode string + +const ( + auditTestGeneratedApp auditTestMode = "generated-app" + auditTestStandalone auditTestMode = "standalone" +) + +type auditScenario struct { + Name string + Method string + Path string + WantStatus int + WantHeader map[string]string +} + +var ( + auditExpectStatusPattern = regexp.MustCompile(`^expect\s+([A-Za-z]+)\s+"([^"]+)"(?:\s+as\s+"([^"]+)")?\s+status\s+([0-9]{3})$`) + auditExpectHeaderPattern = regexp.MustCompile(`^expect\s+header\s+"([^"]+)"\s+"([^"]+)"$`) +) + +// GeneratedAuditTestSource returns the generated-app audit test file source for +// options. It returns nil when there is no IR-backed posture to exercise. +func GeneratedAuditTestSource(options Options) ([]byte, error) { + if options.IR == nil { + return nil, nil + } + manifest := securitymanifest.Build(options.Config, *options.IR) + return auditTestSource("gowdkapp", auditTestGeneratedApp, options.Config, manifest, options.IR.AuditSpecs) +} + +// StandaloneAuditTestSource returns a committable audit test file that drives +// runtime/app directly from the derived posture. The CLI uses this for +// `gowdk audit --emit-tests` and temporary `--run` checks. +func StandaloneAuditTestSource(config gowdk.Config, manifest securitymanifest.SecurityManifest, specs []gwdkir.AuditSpec) ([]byte, error) { + return auditTestSource("gowdkaudit_test", auditTestStandalone, config, manifest, specs) +} + +func auditTestSource(packageName string, mode auditTestMode, config gowdk.Config, manifest securitymanifest.SecurityManifest, specs []gwdkir.AuditSpec) ([]byte, error) { + scenarios, err := auditTestScenarios(config, manifest, specs) + if err != nil { + return nil, err + } + if len(scenarios) == 0 { + return nil, nil + } + + var builder strings.Builder + fmt.Fprintf(&builder, "package %s\n\n", packageName) + writeAuditTestImports(&builder, mode) + builder.WriteString("\n") + builder.WriteString("func TestGOWDKAuditGeneratedSecurityPosture(t *testing.T) {\n") + switch mode { + case auditTestGeneratedApp: + builder.WriteString("\thandler, err := Handler()\n") + builder.WriteString("\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n") + case auditTestStandalone: + writeStandaloneAuditHandler(&builder, config, manifest) + } + builder.WriteString("\tgowdktestkit.Run(t, handler, []gowdktestkit.Scenario{\n") + for _, scenario := range scenarios { + writeAuditScenario(&builder, scenario) + } + builder.WriteString("\t})\n") + builder.WriteString("}\n") + + formatted, err := format.Source([]byte(builder.String())) + if err != nil { + return nil, fmt.Errorf("format generated audit tests: %w", err) + } + return formatted, nil +} + +func writeAuditTestImports(builder *strings.Builder, mode auditTestMode) { + builder.WriteString("import (\n") + builder.WriteString("\t\"net/http\"\n") + builder.WriteString("\t\"testing\"\n") + if mode == auditTestStandalone { + builder.WriteString("\t\"testing/fstest\"\n") + } + builder.WriteString("\n") + if mode == auditTestStandalone { + builder.WriteString("\tgowdkruntime \"github.com/cssbruno/gowdk/runtime/app\"\n") + builder.WriteString("\truntimeasset \"github.com/cssbruno/gowdk/runtime/asset\"\n") + } + builder.WriteString("\tgowdktestkit \"github.com/cssbruno/gowdk/runtime/testkit\"\n") + builder.WriteString(")\n") +} + +func auditTestScenarios(config gowdk.Config, manifest securitymanifest.SecurityManifest, specs []gwdkir.AuditSpec) ([]auditScenario, error) { + var scenarios []auditScenario + routes := append([]securitymanifest.RouteEntry(nil), manifest.Routes...) + sort.SliceStable(routes, func(i, j int) bool { + if routes[i].Route != routes[j].Route { + return routes[i].Route < routes[j].Route + } + return routes[i].PageID < routes[j].PageID + }) + + postEndpoints := map[string]bool{} + for _, endpoint := range manifest.Endpoints { + if strings.EqualFold(endpoint.Method, http.MethodPost) && endpoint.Path != "" { + postEndpoints[path.Clean("/"+endpoint.Path)] = true + } + } + + for _, route := range routes { + routePath := path.Clean("/" + route.Route) + if !isConcreteAuditRoute(routePath) { + continue + } + if route.DefaultDeny { + scenarios = append(scenarios, auditScenario{ + Name: "default-deny " + routePath, + Method: http.MethodGet, + Path: routePath, + WantStatus: http.StatusForbidden, + }) + } else if !strings.EqualFold(route.Render, string(gowdk.SSR)) { + scenarios = append(scenarios, auditScenario{ + Name: "route serves " + routePath, + Method: http.MethodGet, + Path: routePath, + WantStatus: http.StatusOK, + }) + } + if !postEndpoints[routePath] { + scenarios = append(scenarios, auditScenario{ + Name: "method denied " + routePath, + Method: http.MethodPost, + Path: routePath, + WantStatus: http.StatusMethodNotAllowed, + }) + } + } + + for _, header := range auditSecurityHeaders(config) { + scenarios = append(scenarios, auditScenario{ + Name: "security header " + header.Name, + Method: http.MethodGet, + Path: "/_gowdk/health", + WantStatus: http.StatusOK, + WantHeader: map[string]string{header.Name: header.Value}, + }) + } + + testScenarios, err := auditDeclaredTestScenarios(specs) + if err != nil { + return nil, err + } + scenarios = append(scenarios, testScenarios...) + return scenarios, nil +} + +type auditHeaderExpectation struct { + Name string + Value string +} + +func auditSecurityHeaders(config gowdk.Config) []auditHeaderExpectation { + if !config.Build.SecurityHeaders.Enabled || len(config.Build.SecurityHeaders.Headers) == 0 { + return nil + } + values := map[string]string{} + names := make([]string, 0, len(config.Build.SecurityHeaders.Headers)) + seen := map[string]bool{} + for name, value := range config.Build.SecurityHeaders.Headers { + name = strings.TrimSpace(name) + if name == "" { + continue + } + values[name] = value + if !seen[name] { + names = append(names, name) + seen[name] = true + } + } + sort.Strings(names) + headers := make([]auditHeaderExpectation, 0, len(names)) + for _, name := range names { + headers = append(headers, auditHeaderExpectation{Name: name, Value: values[name]}) + } + return headers +} + +func auditDeclaredTestScenarios(specs []gwdkir.AuditSpec) ([]auditScenario, error) { + var scenarios []auditScenario + for _, spec := range specs { + for _, test := range spec.Tests { + lines := strings.Split(test.Body, "\n") + for lineIndex, raw := range lines { + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "//") { + continue + } + statusMatch := auditExpectStatusPattern.FindStringSubmatch(line) + if statusMatch != nil { + status, err := strconv.Atoi(statusMatch[4]) + if err != nil { + return nil, fmt.Errorf("%s:%d: invalid audit test status %q", spec.Source, test.Span.Start.Line+lineIndex+1, statusMatch[4]) + } + name := test.Name + " " + strings.ToUpper(statusMatch[1]) + " " + statusMatch[2] + if statusMatch[3] != "" { + name += " as " + statusMatch[3] + } + scenarios = append(scenarios, auditScenario{ + Name: name, + Method: strings.ToUpper(statusMatch[1]), + Path: statusMatch[2], + WantStatus: status, + }) + continue + } + headerMatch := auditExpectHeaderPattern.FindStringSubmatch(line) + if headerMatch != nil { + scenarios = append(scenarios, auditScenario{ + Name: test.Name + " header " + headerMatch[1], + Method: http.MethodGet, + Path: "/_gowdk/health", + WantStatus: http.StatusOK, + WantHeader: map[string]string{headerMatch[1]: headerMatch[2]}, + }) + continue + } + return nil, fmt.Errorf("%s:%d: unsupported audit test expectation %q", spec.Source, test.Span.Start.Line+lineIndex+1, line) + } + } + } + return scenarios, nil +} + +func writeStandaloneAuditHandler(builder *strings.Builder, config gowdk.Config, manifest securitymanifest.SecurityManifest) { + builder.WriteString("\thandler := gowdkruntime.Handler{\n") + builder.WriteString("\t\tRoot: fstest.MapFS{\n") + for _, file := range auditStandaloneFiles(manifest) { + fmt.Fprintf(builder, "\t\t\t%s: {Data: []byte(%s)},\n", strconv.Quote(file), strconv.Quote("
GOWDK audit
")) + } + builder.WriteString("\t\t},\n") + builder.WriteString("\t\tIdentity: gowdkruntime.Identity{AppID: \"audit\", ModuleName: \"audit\", InstanceID: \"audit-test\"},\n") + builder.WriteString("\t\tAssets: runtimeasset.Manifest{Version: 1, Files: map[string]string{}},\n") + if headers := auditSecurityHeaders(config); len(headers) > 0 { + builder.WriteString("\t\tSecurityHeaders: map[string]string{\n") + for _, header := range headers { + fmt.Fprintf(builder, "\t\t\t%s: %s,\n", strconv.Quote(header.Name), strconv.Quote(header.Value)) + } + builder.WriteString("\t\t},\n") + } + if denied := auditDeniedRoutes(manifest); len(denied) > 0 { + builder.WriteString("\t\tDenied: map[string]bool{\n") + for _, route := range denied { + fmt.Fprintf(builder, "\t\t\t%s: true,\n", strconv.Quote(route)) + } + builder.WriteString("\t\t},\n") + } + if patterns := auditDeniedRoutePatterns(manifest); len(patterns) > 0 { + builder.WriteString("\t\tDeniedPatterns: []string{\n") + for _, route := range patterns { + fmt.Fprintf(builder, "\t\t\t%s,\n", strconv.Quote(route)) + } + builder.WriteString("\t\t},\n") + } + builder.WriteString("\t}\n") +} + +func auditStandaloneFiles(manifest securitymanifest.SecurityManifest) []string { + seen := map[string]bool{} + var files []string + for _, route := range manifest.Routes { + routePath := path.Clean("/" + route.Route) + if !isConcreteAuditRoute(routePath) { + continue + } + file := auditRouteFile(routePath) + if seen[file] { + continue + } + seen[file] = true + files = append(files, file) + } + sort.Strings(files) + return files +} + +func auditRouteFile(route string) string { + route = path.Clean("/" + route) + if route == "/" { + return "index.html" + } + return strings.TrimPrefix(route, "/") + "/index.html" +} + +func auditDeniedRoutes(manifest securitymanifest.SecurityManifest) []string { + var routes []string + for _, route := range manifest.Routes { + routePath := path.Clean("/" + route.Route) + if route.DefaultDeny && isConcreteAuditRoute(routePath) { + routes = append(routes, routePath) + } + } + sort.Strings(routes) + return routes +} + +func auditDeniedRoutePatterns(manifest securitymanifest.SecurityManifest) []string { + var routes []string + for _, route := range manifest.Routes { + routePath := path.Clean("/" + route.Route) + if route.DefaultDeny && !isConcreteAuditRoute(routePath) { + routes = append(routes, routePath) + } + } + sort.Strings(routes) + return routes +} + +func isConcreteAuditRoute(route string) bool { + return !strings.Contains(route, "{") && !strings.Contains(route, "}") +} + +func writeAuditScenario(builder *strings.Builder, scenario auditScenario) { + builder.WriteString("\t\t{\n") + fmt.Fprintf(builder, "\t\t\tName: %s,\n", strconv.Quote(scenario.Name)) + fmt.Fprintf(builder, "\t\t\tMethod: %s,\n", auditMethodExpr(scenario.Method)) + fmt.Fprintf(builder, "\t\t\tPath: %s,\n", strconv.Quote(path.Clean("/"+scenario.Path))) + if scenario.WantStatus != 0 { + fmt.Fprintf(builder, "\t\t\tWantStatus: %s,\n", auditStatusExpr(scenario.WantStatus)) + } + if len(scenario.WantHeader) > 0 { + builder.WriteString("\t\t\tWantHeader: map[string]string{\n") + names := make([]string, 0, len(scenario.WantHeader)) + for name := range scenario.WantHeader { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + fmt.Fprintf(builder, "\t\t\t\t%s: %s,\n", strconv.Quote(name), strconv.Quote(scenario.WantHeader[name])) + } + builder.WriteString("\t\t\t},\n") + } + builder.WriteString("\t\t},\n") +} + +func auditMethodExpr(method string) string { + switch strings.ToUpper(strings.TrimSpace(method)) { + case http.MethodGet: + return "http.MethodGet" + case http.MethodHead: + return "http.MethodHead" + case http.MethodPost: + return "http.MethodPost" + case http.MethodPut: + return "http.MethodPut" + case http.MethodPatch: + return "http.MethodPatch" + case http.MethodDelete: + return "http.MethodDelete" + default: + return strconv.Quote(strings.ToUpper(strings.TrimSpace(method))) + } +} + +func auditStatusExpr(status int) string { + switch status { + case http.StatusOK: + return "http.StatusOK" + case http.StatusNoContent: + return "http.StatusNoContent" + case http.StatusSeeOther: + return "http.StatusSeeOther" + case http.StatusBadRequest: + return "http.StatusBadRequest" + case http.StatusForbidden: + return "http.StatusForbidden" + case http.StatusNotFound: + return "http.StatusNotFound" + case http.StatusMethodNotAllowed: + return "http.StatusMethodNotAllowed" + case http.StatusInternalServerError: + return "http.StatusInternalServerError" + default: + return strconv.Itoa(status) + } +} diff --git a/internal/appgen/files.go b/internal/appgen/files.go index fb76e69f..0f385e59 100644 --- a/internal/appgen/files.go +++ b/internal/appgen/files.go @@ -4,10 +4,11 @@ import ( "bytes" "fmt" "os" - "path" "path/filepath" "sort" "strings" + + "github.com/cssbruno/gowdk/internal/safeasset" ) func validateDirectories(outputDir, appDir string) error { @@ -78,49 +79,11 @@ func copyOutputFiles(sourceRoot, targetRoot string) ([]string, error) { } func unsafeEmbeddedDirectory(rel string) bool { - base := path.Base(filepath.ToSlash(rel)) - switch base { - case ".git", ".hg", ".svn", "node_modules", "tmp", "temp", ".tmp", "private", ".private", "secrets", ".secrets": - return true - default: - return false - } + return safeasset.UnsafeEmbeddedDirectory(rel) } func unsafeEmbeddedFile(rel string) bool { - rel = filepath.ToSlash(rel) - base := path.Base(rel) - normalizedBase := strings.ToLower(base) - ext := path.Ext(normalizedBase) - switch { - case normalizedBase == ".env" || strings.HasPrefix(normalizedBase, ".env."): - return true - case normalizedBase == "gowdk-security.json": - return true - case normalizedBase == ".npmrc" || normalizedBase == ".netrc": - return true - case privateKeyFile(normalizedBase): - return true - case ext == ".map" || ext == ".gwdk" || ext == ".go": - return true - case ext == ".tmp" || ext == ".temp" || strings.HasSuffix(normalizedBase, "~"): - return true - case ext == ".key" || ext == ".pem" || ext == ".p12" || ext == ".pfx": - return true - case strings.HasSuffix(normalizedBase, ".swp") || strings.HasSuffix(normalizedBase, ".swo"): - return true - default: - return false - } -} - -func privateKeyFile(base string) bool { - switch base { - case "id_rsa", "id_dsa", "id_ecdsa", "id_ed25519": - return true - default: - return false - } + return safeasset.UnsafeEmbeddedFile(rel) } func copyFile(sourcePath, targetPath string) error { diff --git a/internal/appgen/source.go b/internal/appgen/source.go index b039bdf6..87db8bea 100644 --- a/internal/appgen/source.go +++ b/internal/appgen/source.go @@ -424,6 +424,9 @@ func embeddedHandlerFields(options Options) []ast.Expr { keyValue("ErrorPages", errorPagesExpr(options)), keyValue("Backend", backend), } + if headers := securityHeadersExpr(options); headers != nil { + fields = append(fields, keyValue("SecurityHeaders", headers)) + } if csrfEnabled(options) { fields = append(fields, keyValue("CSRF", id("csrfTokenSource"))) } @@ -441,6 +444,41 @@ func embeddedHandlerFields(options Options) []ast.Expr { return fields } +func securityHeadersExpr(options Options) ast.Expr { + if !options.Config.Build.SecurityHeaders.Enabled || len(options.Config.Build.SecurityHeaders.Headers) == 0 { + return nil + } + names := make([]string, 0, len(options.Config.Build.SecurityHeaders.Headers)) + values := map[string]string{} + seen := map[string]bool{} + for name, value := range options.Config.Build.SecurityHeaders.Headers { + clean := strings.TrimSpace(name) + if clean == "" { + continue + } + if !seen[clean] { + names = append(names, clean) + seen[clean] = true + } + values[clean] = value + } + if len(names) == 0 { + return nil + } + sort.Strings(names) + elts := make([]ast.Expr, 0, len(names)) + for _, name := range names { + elts = append(elts, &ast.KeyValueExpr{ + Key: stringLit(name), + Value: stringLit(values[name]), + }) + } + return &ast.CompositeLit{ + Type: &ast.MapType{Key: id("string"), Value: id("string")}, + Elts: elts, + } +} + // deniedPageRoutes returns the concrete (non-dynamic) page routes that declared // no guard. Such pages are denied (403) at request time until the author adds // guard public. Request-time (SSR) pages enforce the same default in their own diff --git a/internal/auditspec/auditspec.go b/internal/auditspec/auditspec.go index c9f9336e..f49f0695 100644 --- a/internal/auditspec/auditspec.go +++ b/internal/auditspec/auditspec.go @@ -39,8 +39,14 @@ const ( // RuleRequireHeader requires the app to be configured to emit a response // header. RuleRequireHeader RuleKind = "require_header" + // RuleRequireClientRouteGuards reports client-visible routes that rely on + // default-deny because the source declared no guard. + RuleRequireClientRouteGuards RuleKind = "require_client_route_guards" // RuleNoSecretsInBundle forbids secret-shaped values in embedded output. RuleNoSecretsInBundle RuleKind = "no_secrets_in_bundle" + // RuleDenyRawHTMLSinks reports every raw-HTML sink not allowlisted by a + // RuleAllowRawHTML rule in the same resolved policy. + RuleDenyRawHTMLSinks RuleKind = "deny_raw_html_sinks" // RuleAllowRawHTML allowlists one raw-HTML sink (source:field); every sink // not allowlisted is reported. RuleAllowRawHTML RuleKind = "allow_raw_html" diff --git a/internal/auditspec/baseline.go b/internal/auditspec/baseline.go index eb9c4b6d..6537dc92 100644 --- a/internal/auditspec/baseline.go +++ b/internal/auditspec/baseline.go @@ -70,6 +70,8 @@ func Baseline() []Policy { }, Rules: []Rule{ {Kind: RuleNoSecretsInBundle, Code: "audit_bundle_secret"}, + {Kind: RuleRequireClientRouteGuards, Code: "audit_client_route_unguarded"}, + {Kind: RuleDenyRawHTMLSinks, Code: "audit_raw_html_sink"}, }, }, } diff --git a/internal/auditspec/engine.go b/internal/auditspec/engine.go index 6bf583d0..4b1a7d6c 100644 --- a/internal/auditspec/engine.go +++ b/internal/auditspec/engine.go @@ -284,38 +284,39 @@ func evalFrontend(surface securitymanifest.FrontendSurface, policy Policy) []Fin "Move the secret to a runtime environment variable, or exclude the file from embedded output.")) } case RuleRequireHeader: - if !containsGuard(surface.ConfiguredHeaders, rule.Value) { - findings = append(findings, finding(rule, policy, "frontend", "", + if !containsHeader(surface.ConfiguredHeaders, rule.Value) { + findings = append(findings, finding(rule, policy, "frontend", policy.Source, fmt.Sprintf("generated app does not declare required response header %q", rule.Value), "Enable Build.SecurityHeaders and configure the header.")) } + case RuleRequireClientRouteGuards: + for _, route := range surface.UnguardedRoutes { + findings = append(findings, finding(rule, policy, "route:"+route.Route, route.Source, + fmt.Sprintf("client route %s is guardless and relies on generated default-deny handling", route.Route), + "Declare guard public for an intentionally public page, or add a protective guard.")) + } + case RuleDenyRawHTMLSinks: + findings = append(findings, evalRawHTMLSinks(surface, policy, rule)...) case RuleAllowRawHTML: // Handled by evalRawHTMLSinks against the full allowlist below. } } - findings = append(findings, evalRawHTMLSinks(surface, policy)...) return findings } // evalRawHTMLSinks reports raw-HTML sinks that are not allowlisted by any // RuleAllowRawHTML rule on the matched frontend policy. -func evalRawHTMLSinks(surface securitymanifest.FrontendSurface, policy Policy) []Finding { +func evalRawHTMLSinks(surface securitymanifest.FrontendSurface, policy Policy, rule Rule) []Finding { if len(surface.RawHTMLSinks) == 0 { return nil } allow := map[string]bool{} - guards := false for _, rule := range policy.Rules { if rule.Kind == RuleAllowRawHTML { - guards = true allow[rule.Value] = true } } - if !guards { - return nil - } var findings []Finding - rule := Rule{Kind: RuleAllowRawHTML, Code: "audit_raw_html_sink"} for _, sink := range surface.RawHTMLSinks { key := sink.Source if allow[key] || allow[sink.OwnerID+":"+sink.Field] { @@ -358,6 +359,16 @@ func containsGuard(guards []string, want string) bool { return false } +func containsHeader(headers []securitymanifest.ConfiguredHeader, want string) bool { + want = strings.TrimSpace(want) + for _, header := range headers { + if strings.EqualFold(strings.TrimSpace(header.Name), want) { + return true + } + } + return false +} + // ParseSelector classifies a raw selector string. func ParseSelector(raw string) Selector { raw = strings.TrimSpace(raw) diff --git a/internal/auditspec/engine_test.go b/internal/auditspec/engine_test.go index 6c6f1f00..d70e4c28 100644 --- a/internal/auditspec/engine_test.go +++ b/internal/auditspec/engine_test.go @@ -57,6 +57,74 @@ func TestBaselinePassesWhenPostureIsSound(t *testing.T) { } } +func TestBaselineFlagsFrontendAuditFindings(t *testing.T) { + manifest := securitymanifest.SecurityManifest{ + Frontend: securitymanifest.FrontendSurface{ + UnguardedRoutes: []securitymanifest.UnguardedRoute{{Route: "/draft", Source: "draft.page.gwdk:4"}}, + BundleSecrets: []securitymanifest.BundleLeak{{Kind: "unsafe-asset:.env", Source: "card.cmp.gwdk:4"}}, + RawHTMLSinks: []securitymanifest.RawHTMLSink{{OwnerKind: "page", OwnerID: "home", Field: "{TrustedHTML}", Source: "home.page.gwdk:12"}}, + }, + } + findings := Evaluate(manifest, Baseline()) + got := codes(findings) + if got["audit_bundle_secret"] != 1 { + t.Fatalf("expected one bundle secret finding, got %#v", got) + } + if got["audit_client_route_unguarded"] != 1 { + t.Fatalf("expected one client route finding, got %#v", got) + } + if got["audit_raw_html_sink"] != 1 { + t.Fatalf("expected one raw HTML finding, got %#v", got) + } + for _, finding := range findings { + if finding.Source == "" { + t.Fatalf("frontend finding should include a source: %#v", finding) + } + } +} + +func TestPolicyRequireHeaderUsesConfiguredHeaders(t *testing.T) { + policy := Policy{ + Name: "headers", + Source: "security.audit.gwdk:3", + Selectors: []Selector{{Raw: "frontend", Kind: SelectorFrontend}}, + Rules: []Rule{{Kind: RuleRequireHeader, Value: "Content-Security-Policy", Code: "audit_headers_missing"}}, + } + missing := securitymanifest.SecurityManifest{Frontend: securitymanifest.FrontendSurface{ + ConfiguredHeaders: []securitymanifest.ConfiguredHeader{{Name: "X-Content-Type-Options"}}, + }} + if got := codes(Evaluate(missing, []Policy{policy})); got["audit_headers_missing"] != 1 { + t.Fatalf("expected missing header finding, got %#v", got) + } + present := securitymanifest.SecurityManifest{Frontend: securitymanifest.FrontendSurface{ + ConfiguredHeaders: []securitymanifest.ConfiguredHeader{{Name: "content-security-policy"}}, + }} + if findings := Evaluate(present, []Policy{policy}); len(findings) != 0 { + t.Fatalf("expected configured header to satisfy policy, got %#v", findings) + } +} + +func TestComposeBaselineLetsDeclaredPolicyOverrideBuiltin(t *testing.T) { + policies := ComposeBaseline([]Policy{{ + Name: "baseline.frontend", + Selectors: []Selector{{Raw: "frontend", Kind: SelectorFrontend}}, + Rules: []Rule{{Kind: RuleRequireHeader, Value: "Content-Security-Policy", Code: "audit_headers_missing"}}, + }}) + for _, policy := range policies { + if policy.Name != "baseline.frontend" { + continue + } + if policy.Builtin { + t.Fatalf("declared override should replace builtin baseline.frontend: %#v", policy) + } + if len(policy.Rules) != 1 || policy.Rules[0].Kind != RuleRequireHeader { + t.Fatalf("unexpected overridden frontend policy: %#v", policy) + } + return + } + t.Fatalf("baseline.frontend missing from composed policies: %#v", policies) +} + func TestSeverityComesFromRegistry(t *testing.T) { manifest := securitymanifest.SecurityManifest{ Endpoints: []securitymanifest.EndpointEntry{ diff --git a/internal/auditspec/ir.go b/internal/auditspec/ir.go new file mode 100644 index 00000000..92bb8e3f --- /dev/null +++ b/internal/auditspec/ir.go @@ -0,0 +1,63 @@ +package auditspec + +import ( + "fmt" + + "github.com/cssbruno/gowdk/internal/gwdkir" + "github.com/cssbruno/gowdk/internal/source" +) + +// PoliciesFromIR converts parsed *.audit.gwdk specs into engine policies. +func PoliciesFromIR(specs []gwdkir.AuditSpec) []Policy { + var policies []Policy + for _, spec := range specs { + for _, policy := range spec.Policies { + out := Policy{ + Name: policy.Name, + Extends: append([]string(nil), policy.Extends...), + Source: sourceRef(spec.Source, policy.Span), + } + for _, apply := range policy.Applies { + out.Selectors = append(out.Selectors, ParseSelector(apply.Selector)) + } + for _, rule := range policy.Rules { + out.Rules = append(out.Rules, Rule{ + Kind: RuleKind(rule.Kind), + Value: rule.Value, + Code: rule.Code, + }) + } + policies = append(policies, out) + } + } + return policies +} + +// ComposeBaseline returns the built-in baseline with declared policies appended. +// A declared policy with the same name as a built-in baseline policy replaces +// that built-in policy so projects can intentionally override a baseline slice. +func ComposeBaseline(declared []Policy) []Policy { + out := Baseline() + byName := map[string]int{} + for index, policy := range out { + byName[policy.Name] = index + } + for _, policy := range declared { + if index, ok := byName[policy.Name]; ok && out[index].Builtin { + out[index] = policy + continue + } + out = append(out, policy) + } + return out +} + +func sourceRef(file string, span source.SourceSpan) string { + if file == "" { + return "" + } + if span.Start.Line > 0 { + return fmt.Sprintf("%s:%d", file, span.Start.Line) + } + return file +} diff --git a/internal/gwdkanalysis/ir_builder.go b/internal/gwdkanalysis/ir_builder.go index 117a98dc..95e9ac98 100644 --- a/internal/gwdkanalysis/ir_builder.go +++ b/internal/gwdkanalysis/ir_builder.go @@ -15,6 +15,7 @@ type Sources struct { Pages []gwdkir.Page Components []gwdkir.Component Layouts []gwdkir.Layout + AuditSpecs []gwdkir.AuditSpec } // BuildProgram assembles the stable compiler IR from parsed IR records: @@ -38,6 +39,9 @@ func BuildProgram(config gowdk.Config, sources Sources) gwdkir.Program { for _, layout := range sources.Layouts { builder.addLayout(layout) } + for _, audit := range sources.AuditSpecs { + builder.addAuditSpec(audit) + } builder.finishPackages() builder.sortOutput() @@ -65,6 +69,12 @@ func (builder *irBuilder) ensurePackage(name string, src string) *gwdkir.Package return pkg } +func (builder *irBuilder) addAuditSpec(audit gwdkir.AuditSpec) { + builder.program.AuditSpecs = append(builder.program.AuditSpecs, audit) + pkg := builder.ensurePackage(audit.Package, audit.Source) + pkg.Files = append(pkg.Files, gwdkir.SourceFile{Path: audit.Source, Kind: gwdkir.SourceAudit, Package: audit.Package, Name: filepath.Base(audit.Source), Span: audit.Span}) +} + func (builder *irBuilder) addPage(page gwdkir.Page) { // Normalize route params once at program assembly: explicit declarations // win, otherwise they are derived from the route pattern, and untyped diff --git a/internal/gwdkast/audit.go b/internal/gwdkast/audit.go new file mode 100644 index 00000000..40e9c665 --- /dev/null +++ b/internal/gwdkast/audit.go @@ -0,0 +1,41 @@ +package gwdkast + +import "github.com/cssbruno/gowdk/internal/source" + +// AuditFile is the typed AST for a *.audit.gwdk source. +type AuditFile struct { + Package *Package + Policies []AuditPolicy + Tests []AuditTest +} + +// AuditPolicy declares a composable security policy. +type AuditPolicy struct { + Name string + Extends []string + Applies []AuditApply + Rules []AuditRule + Span source.SourceSpan +} + +// AuditApply applies a policy to one selector. +type AuditApply struct { + Selector string + Span source.SourceSpan +} + +// AuditRule declares one policy rule. +type AuditRule struct { + Kind string + Value string + Code string + Span source.SourceSpan +} + +// AuditTest preserves one declared audit integration test block for generated +// runtime tests. +type AuditTest struct { + Name string + Body string + Span source.SourceSpan +} diff --git a/internal/gwdkir/ir.go b/internal/gwdkir/ir.go index 1adf9165..1cbe70e5 100644 --- a/internal/gwdkir/ir.go +++ b/internal/gwdkir/ir.go @@ -21,6 +21,7 @@ type Program struct { GoEndpoints []GoEndpoint Templates []Template ContractRefs []ContractReference + AuditSpecs []AuditSpec ClientBehaviors []ClientBehavior Assets []Asset Diagnostics []Diagnostic @@ -60,6 +61,7 @@ const ( SourcePage SourceKind = "page" SourceComponent SourceKind = "component" SourceLayout SourceKind = "layout" + SourceAudit SourceKind = "audit" ) // Import records a Go package import used by analyzed source. @@ -471,6 +473,46 @@ const ( ContractBindingInvalid ContractBindingStatus = "invalid" ) +// AuditSpec is the normalized IR for one *.audit.gwdk source. +type AuditSpec struct { + Source string + Package string + Policies []AuditPolicy + Tests []AuditTest + Span source.SourceSpan +} + +// AuditPolicy declares a composable policy that can extend other policies and +// apply rules to selectors. +type AuditPolicy struct { + Name string + Extends []string + Applies []AuditApply + Rules []AuditRule + Span source.SourceSpan +} + +// AuditApply records one selector applied to a declared policy. +type AuditApply struct { + Selector string + Span source.SourceSpan +} + +// AuditRule records one declared policy rule. +type AuditRule struct { + Kind string + Value string + Code string + Span source.SourceSpan +} + +// AuditTest preserves a declared test block for Phase 4 runtime verification. +type AuditTest struct { + Name string + Body string + Span source.SourceSpan +} + // ClientBehavior records a compiler-owned client block. The body is retained // until the client language has a dedicated full AST. type ClientBehavior struct { diff --git a/internal/lang/completion.go b/internal/lang/completion.go index 396d1dc8..9bba8a7e 100644 --- a/internal/lang/completion.go +++ b/internal/lang/completion.go @@ -41,6 +41,13 @@ func Completions() []Completion { {Label: "ref", Detail: "Declare a typed DOM ref for supported safe methods."}, {Label: "act", Detail: "Declare an action endpoint backed by an exported Go handler."}, {Label: "api", Detail: "Declare an API endpoint backed by an exported Go handler."}, + {Label: "policy", Detail: "Declare a composable audit policy."}, + {Label: "extends", Detail: "Compose an audit policy from another policy."}, + {Label: "apply to", Detail: "Apply an audit policy to a selector."}, + {Label: "match", Detail: "Apply an audit policy to a selector."}, + {Label: "require", Detail: "Declare an audit policy requirement."}, + {Label: "deny", Detail: "Declare an audit policy denial."}, + {Label: "expect", Detail: "Declare an audit integration test expectation."}, {Label: "view", Detail: "Markup render block."}, {Label: "g:post", Detail: "Bind a form to an action."}, {Label: "g:target", Detail: "Select partial update target."}, diff --git a/internal/lang/redact.go b/internal/lang/redact.go index cabed953..e88ec512 100644 --- a/internal/lang/redact.go +++ b/internal/lang/redact.go @@ -1,6 +1,6 @@ package lang -import "regexp" +import "github.com/cssbruno/gowdk/internal/securitytext" // RedactMessage masks values that commonly carry credentials so a diagnostic // quoting .gwdk source content (attribute values, expressions, route or store @@ -10,39 +10,5 @@ import "regexp" // It is deliberately conservative and mirrors the runtime panic-log policy: it // favours over-masking a suspicious token over letting a real secret through. func RedactMessage(message string) string { - if message == "" { - return message - } - for _, rule := range redactionRules { - message = rule.pattern.ReplaceAllString(message, rule.replacement) - } - return message -} - -const redactionMask = "[REDACTED]" - -// Rules run in order. The DSN and Bearer/Basic scheme rules run before the -// generic key=value rule so an "Authorization: Bearer " string has its -// token masked by the scheme rule rather than half-consumed by the key rule. -var redactionRules = []struct { - pattern *regexp.Regexp - replacement string -}{ - // scheme://user:password@host (DB DSNs, connection strings) - { - pattern: regexp.MustCompile(`([a-zA-Z][a-zA-Z0-9+.-]*://[^:/@\s]+:)[^@/\s]+(@)`), - replacement: `${1}` + redactionMask + `${2}`, - }, - // Authorization header style: "Bearer " / "Basic " - { - pattern: regexp.MustCompile(`(?i)\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}`), - replacement: `${1} ` + redactionMask, - }, - // key=value / key: value where key names a secret-bearing header, cookie, - // form field, query param, or credential. Requires an explicit : or = - // separator so it does not swallow following words by whitespace. - { - pattern: regexp.MustCompile(`(?i)\b(password|passwd|pwd|secret|token|_?gowdk[_-]?csrf|_?csrf(?:[_-]?token)?|cookie|set-cookie|auth[_-]?token|session(?:[_-]?id)?|jwt|api[_-]?key|access(?:[_-]?(?:key|token))?|refresh[_-]?token|id[_-]?token|client[_-]?secret|private[_-]?key)\b(\s*[:=]\s*)("?)[^\s"',;)]+`), - replacement: `${1}${2}${3}` + redactionMask, - }, + return securitytext.RedactSecrets(message) } diff --git a/internal/lang/tools.go b/internal/lang/tools.go index 9a595e94..7bdee499 100644 --- a/internal/lang/tools.go +++ b/internal/lang/tools.go @@ -25,6 +25,7 @@ const ( FileKindComponent FileKind = "component" FileKindLayout FileKind = "layout" FileKindAsset FileKind = "asset" + FileKindAudit FileKind = "audit" ) // ParseFile reads and parses one .gwdk file. @@ -165,6 +166,13 @@ func ParseBuildFiles(paths []string) (gwdkanalysis.Sources, Diagnostics) { continue } switch ClassifySource(path, source) { + case FileKindAudit: + audit, fileDiagnostics := ParseAuditSource(path, source) + diagnostics = append(diagnostics, fileDiagnostics...) + if !fileDiagnostics.HasErrors() { + app.AuditSpecs = append(app.AuditSpecs, audit) + } + continue case FileKindComponent: component, fileDiagnostics := ParseComponentSource(path, source) diagnostics = append(diagnostics, fileDiagnostics...) @@ -227,9 +235,29 @@ func ParseComponentSource(path string, source []byte) (gwdkir.Component, Diagnos return component, diagnostics } +// ParseAuditSource parses one in-memory *.audit.gwdk source buffer. +func ParseAuditSource(path string, source []byte) (gwdkir.AuditSpec, Diagnostics) { + _, diagnostics := Lex(string(source)) + for i := range diagnostics { + diagnostics[i].File = path + } + if diagnostics.HasErrors() { + return gwdkir.AuditSpec{}, diagnostics + } + + audit, err := parser.ParseAuditFile(path, source) + if err != nil { + diagnostics = append(diagnostics, parserDiagnostics(path, source, err)...) + } + return audit, diagnostics +} + // ClassifySource classifies a .gwdk source file using current file-kind rules. func ClassifySource(path string, source []byte) FileKind { base := filepath.Base(path) + if strings.HasSuffix(base, ".audit.gwdk") { + return FileKindAudit + } if strings.HasSuffix(base, ".cmp.gwdk") { return FileKindComponent } @@ -245,6 +273,8 @@ func ClassifySource(path string, source []byte) FileKind { continue } switch { + case isMetadataDeclaration(text, "policy"): + return FileKindAudit case isMetadataDeclaration(text, "page"): return FileKindPage case isMetadataDeclaration(text, "component"): @@ -354,6 +384,13 @@ func contractScanDiagnostics(scanDiagnostics []contractscan.Diagnostic) Diagnost // CheckSource parses and validates one in-memory .gwdk source buffer. func CheckSource(config gowdk.Config, path string, source []byte) (gwdkir.Page, Diagnostics) { switch ClassifySource(path, source) { + case FileKindAudit: + audit, diagnostics := ParseAuditSource(path, source) + if diagnostics.HasErrors() { + return gwdkir.Page{}, diagnostics + } + _ = gwdkanalysis.BuildProgram(config, gwdkanalysis.Sources{AuditSpecs: []gwdkir.AuditSpec{audit}}) + return gwdkir.Page{}, diagnostics case FileKindComponent: component, diagnostics := ParseComponentSource(path, source) if diagnostics.HasErrors() { diff --git a/internal/lang/tools_test.go b/internal/lang/tools_test.go index b150ffdb..23672639 100644 --- a/internal/lang/tools_test.go +++ b/internal/lang/tools_test.go @@ -398,6 +398,8 @@ func TestClassifySourceUsesCurrentFileKindRules(t *testing.T) { {"root.gwdk", "layout root", FileKindLayout}, {"root.layout.gwdk", "layout root", FileKindLayout}, {"images.asset.gwdk", "asset images", FileKindAsset}, + {"security.audit.gwdk", "policy frontend {", FileKindAudit}, + {"security.gwdk", "policy frontend {", FileKindAudit}, } for _, tc := range cases { diff --git a/internal/parser/audit.go b/internal/parser/audit.go new file mode 100644 index 00000000..eab2ae60 --- /dev/null +++ b/internal/parser/audit.go @@ -0,0 +1,375 @@ +package parser + +import ( + "bufio" + "bytes" + "fmt" + "strings" + + "github.com/cssbruno/gowdk/internal/gwdkast" + "github.com/cssbruno/gowdk/internal/gwdkir" + "github.com/cssbruno/gowdk/internal/source" + "github.com/cssbruno/gowdk/internal/syntax" +) + +type capturedAuditBlock struct { + kind string + name string + extends []string + span source.SourceSpan + body []syntaxBodyLine + scanner braceScanner + depth int +} + +// ParseAuditFile parses a *.audit.gwdk source into audit IR. +func ParseAuditFile(path string, src []byte) (gwdkir.AuditSpec, error) { + ast, err := ParseAuditSyntax(src) + if err != nil { + return gwdkir.AuditSpec{}, err + } + return lowerAuditSyntax(path, ast), nil +} + +// ParseAuditSyntax parses the dedicated audit policy file kind. +func ParseAuditSyntax(src []byte) (gwdkast.AuditFile, error) { + var file gwdkast.AuditFile + var parseErrors []error + addError := func(err error) { + if err != nil { + parseErrors = append(parseErrors, err) + } + } + + var captured *capturedAuditBlock + seenDeclaration := false + + scanner := bufio.NewScanner(bytes.NewReader(src)) + for lineNumber := 1; scanner.Scan(); lineNumber++ { + rawLine := scanner.Text() + line := strings.TrimSpace(rawLine) + if captured != nil { + if line == "}" && !captured.scanner.inMultiline() && captured.depth == 1 { + addError(finishAuditBlock(&file, *captured)) + captured = nil + continue + } + captured.depth += captured.scanner.delta(rawLine) + if captured.depth <= 0 { + addError(finishAuditBlock(&file, *captured)) + captured = nil + continue + } + captured.body = append(captured.body, syntaxBodyLine{Text: rawLine, Line: lineNumber}) + continue + } + + if line == "" || strings.HasPrefix(line, "//") { + continue + } + if match := packagePattern.FindStringSubmatch(line); match != nil { + if seenDeclaration { + addError(lineDiagnosticError(DiagnosticPackageMustBeFirst, lineNumber, rawLine, "package declaration must be the first non-comment declaration")) + continue + } + pkg := gwdkast.Package{Name: match[1], Span: sourceLineSpan(lineNumber, rawLine)} + file.Package = &pkg + seenDeclaration = true + continue + } + seenDeclaration = true + if policy, ok, err := parseAuditPolicyStart(line, lineNumber, rawLine); ok || err != nil { + if err != nil { + addError(err) + continue + } + captured = &capturedAuditBlock{kind: "policy", name: policy.Name, extends: policy.Extends, span: policy.Span, scanner: braceScanner{lang: braceLangGo}, depth: 1} + continue + } + if test, ok, err := parseAuditTestStart(line, lineNumber, rawLine); ok || err != nil { + if err != nil { + addError(err) + continue + } + captured = &capturedAuditBlock{kind: "test", name: test.Name, span: test.Span, scanner: braceScanner{lang: braceLangGo}, depth: 1} + continue + } + addError(fmt.Errorf("line %d: unsupported audit declaration %q", lineNumber, line)) + } + if err := scanner.Err(); err != nil { + addError(err) + } + if captured != nil { + addError(fmt.Errorf("line %d: unterminated %s block %q", captured.span.Start.Line, captured.kind, captured.name)) + } + if len(parseErrors) > 0 { + return gwdkast.AuditFile{}, diagnosticErrors(parseErrors) + } + return file, nil +} + +func parseAuditPolicyStart(line string, lineNumber int, rawLine string) (gwdkast.AuditPolicy, bool, error) { + tokens := syntaxTokens(line) + if len(tokens) == 0 || tokens[0].Lexeme != "policy" { + return gwdkast.AuditPolicy{}, false, nil + } + if len(tokens) < 3 || tokens[len(tokens)-1].Kind != syntax.TokenLBrace { + return gwdkast.AuditPolicy{}, true, fmt.Errorf("line %d: policy declaration must use policy [extends [, ...]] {", lineNumber) + } + name, ok := auditName(tokens[1]) + if !ok { + return gwdkast.AuditPolicy{}, true, fmt.Errorf("line %d: policy name must be an identifier or string", lineNumber) + } + policy := gwdkast.AuditPolicy{Name: name, Span: sourceLineSpan(lineNumber, rawLine)} + index := 2 + if index < len(tokens)-1 { + if tokens[index].Lexeme != "extends" { + return gwdkast.AuditPolicy{}, true, fmt.Errorf("line %d: unexpected policy declaration token %q", lineNumber, tokens[index].Lexeme) + } + index++ + for index < len(tokens)-1 { + parent, ok := auditName(tokens[index]) + if !ok { + return gwdkast.AuditPolicy{}, true, fmt.Errorf("line %d: extends target must be an identifier or string", lineNumber) + } + policy.Extends = append(policy.Extends, parent) + index++ + if index < len(tokens)-1 { + if tokens[index].Kind != syntax.TokenComma { + return gwdkast.AuditPolicy{}, true, fmt.Errorf("line %d: extends targets must be comma-separated", lineNumber) + } + index++ + } + } + } + return policy, true, nil +} + +func parseAuditTestStart(line string, lineNumber int, rawLine string) (gwdkast.AuditTest, bool, error) { + tokens := syntaxTokens(line) + if len(tokens) == 0 || tokens[0].Lexeme != "test" { + return gwdkast.AuditTest{}, false, nil + } + if len(tokens) != 3 || tokens[2].Kind != syntax.TokenLBrace { + return gwdkast.AuditTest{}, true, fmt.Errorf("line %d: test declaration must use test {", lineNumber) + } + name, ok := auditName(tokens[1]) + if !ok { + return gwdkast.AuditTest{}, true, fmt.Errorf("line %d: test name must be an identifier or string", lineNumber) + } + return gwdkast.AuditTest{Name: name, Span: sourceLineSpan(lineNumber, rawLine)}, true, nil +} + +func auditName(token syntax.Token) (string, bool) { + switch token.Kind { + case syntax.TokenIdentifier: + return token.Lexeme, true + case syntax.TokenString: + return decodeStringLiteral(token.Lexeme), true + default: + return "", false + } +} + +func finishAuditBlock(file *gwdkast.AuditFile, block capturedAuditBlock) error { + switch block.kind { + case "policy": + policy := gwdkast.AuditPolicy{Name: block.name, Extends: append([]string(nil), block.extends...), Span: block.span} + for _, raw := range block.body { + line := strings.TrimSpace(raw.Text) + if line == "" || strings.HasPrefix(line, "//") { + continue + } + if apply, ok, err := parseAuditApply(line, raw.Line, raw.Text); ok || err != nil { + if err != nil { + return err + } + policy.Applies = append(policy.Applies, apply) + continue + } + if rule, ok, err := parseAuditRule(line, raw.Line, raw.Text); ok || err != nil { + if err != nil { + return err + } + policy.Rules = append(policy.Rules, rule) + continue + } + return fmt.Errorf("line %d: unsupported policy syntax %q", raw.Line, line) + } + file.Policies = append(file.Policies, policy) + case "test": + file.Tests = append(file.Tests, gwdkast.AuditTest{Name: block.name, Body: strings.TrimSpace(joinSyntaxBody(block.body)), Span: block.span}) + } + return nil +} + +func parseAuditApply(line string, lineNumber int, rawLine string) (gwdkast.AuditApply, bool, error) { + tokens := syntaxTokens(line) + if len(tokens) == 0 { + return gwdkast.AuditApply{}, false, nil + } + switch { + case tokens[0].Lexeme == "match": + if len(tokens) != 2 || tokens[1].Kind != syntax.TokenString { + return gwdkast.AuditApply{}, true, fmt.Errorf("line %d: match must use match \"\"", lineNumber) + } + return gwdkast.AuditApply{Selector: decodeStringLiteral(tokens[1].Lexeme), Span: sourceLineSpan(lineNumber, rawLine)}, true, nil + case tokens[0].Lexeme == "apply": + if len(tokens) != 3 || tokens[1].Lexeme != "to" || tokens[2].Kind != syntax.TokenString { + return gwdkast.AuditApply{}, true, fmt.Errorf("line %d: apply must use apply to \"\"", lineNumber) + } + return gwdkast.AuditApply{Selector: decodeStringLiteral(tokens[2].Lexeme), Span: sourceLineSpan(lineNumber, rawLine)}, true, nil + default: + return gwdkast.AuditApply{}, false, nil + } +} + +func parseAuditRule(line string, lineNumber int, rawLine string) (gwdkast.AuditRule, bool, error) { + tokens := syntaxTokens(line) + if len(tokens) == 0 { + return gwdkast.AuditRule{}, false, nil + } + switch tokens[0].Lexeme { + case "require": + return parseAuditRequireRule(tokens, lineNumber, rawLine) + case "deny": + return parseAuditDenyRule(tokens, lineNumber, rawLine) + case "allow": + return parseAuditAllowRule(tokens, lineNumber, rawLine) + default: + return gwdkast.AuditRule{}, false, nil + } +} + +func parseAuditRequireRule(tokens []syntax.Token, lineNumber int, rawLine string) (gwdkast.AuditRule, bool, error) { + if len(tokens) < 2 { + return gwdkast.AuditRule{}, true, fmt.Errorf("line %d: require rule is missing a subject", lineNumber) + } + rule := gwdkast.AuditRule{Span: sourceLineSpan(lineNumber, rawLine)} + switch tokens[1].Lexeme { + case "csrf": + rule.Kind = "require_csrf" + rule.Code = "audit_action_missing_csrf" + return finishAuditRule(rule, tokens[2:], lineNumber) + case "guard": + if len(tokens) < 3 { + return gwdkast.AuditRule{}, true, fmt.Errorf("line %d: require guard needs a guard value", lineNumber) + } + value, ok := auditValue(tokens[2]) + if !ok { + return gwdkast.AuditRule{}, true, fmt.Errorf("line %d: require guard value must be an identifier or string", lineNumber) + } + rule.Kind = "require_guard" + rule.Value = value + rule.Code = "audit_required_guard_missing" + return finishAuditRule(rule, tokens[3:], lineNumber) + case "header": + if len(tokens) < 3 || tokens[2].Kind != syntax.TokenString { + return gwdkast.AuditRule{}, true, fmt.Errorf("line %d: require header needs a string header name", lineNumber) + } + rule.Kind = "require_header" + rule.Value = decodeStringLiteral(tokens[2].Lexeme) + rule.Code = "audit_headers_missing" + return finishAuditRule(rule, tokens[3:], lineNumber) + case "max_body": + if len(tokens) < 3 { + return gwdkast.AuditRule{}, true, fmt.Errorf("line %d: require max_body needs a size", lineNumber) + } + value, ok := auditValue(tokens[2]) + if !ok { + return gwdkast.AuditRule{}, true, fmt.Errorf("line %d: require max_body size must be an identifier or string", lineNumber) + } + rule.Kind = "max_body" + rule.Value = value + rule.Code = "audit_max_body_exceeds_policy" + return finishAuditRule(rule, tokens[3:], lineNumber) + case "no_secrets_in_bundle": + rule.Kind = "no_secrets_in_bundle" + rule.Code = "audit_bundle_secret" + return finishAuditRule(rule, tokens[2:], lineNumber) + default: + return gwdkast.AuditRule{}, true, fmt.Errorf("line %d: unsupported require rule %q", lineNumber, tokens[1].Lexeme) + } +} + +func parseAuditDenyRule(tokens []syntax.Token, lineNumber int, rawLine string) (gwdkast.AuditRule, bool, error) { + if len(tokens) < 2 { + return gwdkast.AuditRule{}, true, fmt.Errorf("line %d: deny rule is missing a subject", lineNumber) + } + rule := gwdkast.AuditRule{Span: sourceLineSpan(lineNumber, rawLine)} + switch tokens[1].Lexeme { + case "public": + rule.Kind = "deny_public" + rule.Code = "audit_public_not_allowed" + return finishAuditRule(rule, tokens[2:], lineNumber) + case "raw_html": + rule.Kind = "deny_raw_html_sinks" + rule.Code = "audit_raw_html_sink" + return finishAuditRule(rule, tokens[2:], lineNumber) + default: + return gwdkast.AuditRule{}, true, fmt.Errorf("line %d: unsupported deny rule %q", lineNumber, tokens[1].Lexeme) + } +} + +func parseAuditAllowRule(tokens []syntax.Token, lineNumber int, rawLine string) (gwdkast.AuditRule, bool, error) { + if len(tokens) < 3 || tokens[1].Lexeme != "raw_html" { + return gwdkast.AuditRule{}, true, fmt.Errorf("line %d: allow currently supports allow raw_html \"\"", lineNumber) + } + value, ok := auditValue(tokens[2]) + if !ok { + return gwdkast.AuditRule{}, true, fmt.Errorf("line %d: allow raw_html value must be an identifier or string", lineNumber) + } + rule := gwdkast.AuditRule{Kind: "allow_raw_html", Value: value, Span: sourceLineSpan(lineNumber, rawLine)} + return finishAuditRule(rule, tokens[3:], lineNumber) +} + +func finishAuditRule(rule gwdkast.AuditRule, tail []syntax.Token, lineNumber int) (gwdkast.AuditRule, bool, error) { + if len(tail) == 0 { + return rule, true, nil + } + if len(tail) == 2 && tail[0].Lexeme == "as" { + code, ok := auditValue(tail[1]) + if !ok { + return gwdkast.AuditRule{}, true, fmt.Errorf("line %d: rule diagnostic code must be an identifier or string", lineNumber) + } + rule.Code = code + return rule, true, nil + } + return gwdkast.AuditRule{}, true, fmt.Errorf("line %d: unsupported trailing rule syntax", lineNumber) +} + +func auditValue(token syntax.Token) (string, bool) { + switch token.Kind { + case syntax.TokenIdentifier: + return token.Lexeme, true + case syntax.TokenString: + return decodeStringLiteral(token.Lexeme), true + default: + return "", false + } +} + +func lowerAuditSyntax(path string, ast gwdkast.AuditFile) gwdkir.AuditSpec { + spec := gwdkir.AuditSpec{Source: path} + if ast.Package != nil { + spec.Package = ast.Package.Name + spec.Span = ast.Package.Span + } + for _, policy := range ast.Policies { + out := gwdkir.AuditPolicy{Name: policy.Name, Extends: append([]string(nil), policy.Extends...), Span: policy.Span} + for _, apply := range policy.Applies { + out.Applies = append(out.Applies, gwdkir.AuditApply{Selector: apply.Selector, Span: apply.Span}) + } + for _, rule := range policy.Rules { + out.Rules = append(out.Rules, gwdkir.AuditRule{Kind: rule.Kind, Value: rule.Value, Code: rule.Code, Span: rule.Span}) + } + spec.Policies = append(spec.Policies, out) + } + for _, test := range ast.Tests { + spec.Tests = append(spec.Tests, gwdkir.AuditTest{Name: test.Name, Body: test.Body, Span: test.Span}) + } + if spec.Span.Start.Line == 0 && len(spec.Policies) > 0 { + spec.Span = spec.Policies[0].Span + } + return spec +} diff --git a/internal/parser/audit_test.go b/internal/parser/audit_test.go new file mode 100644 index 00000000..57116881 --- /dev/null +++ b/internal/parser/audit_test.go @@ -0,0 +1,55 @@ +package parser + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseAuditFileGolden(t *testing.T) { + path := filepath.Join("testdata", "golden", "security.audit.gwdk") + source, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + spec, err := ParseAuditFile(path, source) + if err != nil { + t.Fatal(err) + } + payload, err := json.MarshalIndent(struct { + Package string `json:"package"` + Policies any `json:"policies"` + Tests any `json:"tests"` + }{ + Package: spec.Package, + Policies: spec.Policies, + Tests: spec.Tests, + }, "", " ") + if err != nil { + t.Fatal(err) + } + payload = append(payload, '\n') + goldenPath := filepath.Join("testdata", "golden", "audit.golden.json") + golden, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatal(err) + } + if string(payload) != string(golden) { + t.Fatalf("audit golden mismatch\n got:\n%s\nwant:\n%s", payload, golden) + } +} + +func TestParseAuditSyntaxReportsUnsupportedPolicyLine(t *testing.T) { + _, err := ParseAuditSyntax([]byte(`policy bad { + surprise now +} +`)) + if err == nil { + t.Fatal("expected unsupported policy line error") + } + if !strings.Contains(err.Error(), `unsupported policy syntax "surprise now"`) { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/parser/testdata/golden/audit.golden.json b/internal/parser/testdata/golden/audit.golden.json new file mode 100644 index 00000000..119ef8ef --- /dev/null +++ b/internal/parser/testdata/golden/audit.golden.json @@ -0,0 +1,179 @@ +{ + "package": "security", + "policies": [ + { + "Name": "admin", + "Extends": [ + "baseline.api" + ], + "Applies": [ + { + "Selector": "/admin/**", + "Span": { + "Start": { + "Line": 4, + "Column": 3, + "Offset": 0 + }, + "End": { + "Line": 4, + "Column": 20, + "Offset": 0 + } + } + } + ], + "Rules": [ + { + "Kind": "require_guard", + "Value": "role:admin", + "Code": "audit_required_guard_missing", + "Span": { + "Start": { + "Line": 5, + "Column": 3, + "Offset": 0 + }, + "End": { + "Line": 5, + "Column": 29, + "Offset": 0 + } + } + }, + { + "Kind": "deny_public", + "Value": "", + "Code": "audit_public_not_allowed", + "Span": { + "Start": { + "Line": 6, + "Column": 3, + "Offset": 0 + }, + "End": { + "Line": 6, + "Column": 14, + "Offset": 0 + } + } + }, + { + "Kind": "max_body", + "Value": "256kb", + "Code": "audit_max_body_exceeds_policy", + "Span": { + "Start": { + "Line": 7, + "Column": 3, + "Offset": 0 + }, + "End": { + "Line": 7, + "Column": 27, + "Offset": 0 + } + } + }, + { + "Kind": "require_header", + "Value": "Content-Security-Policy", + "Code": "audit_headers_missing", + "Span": { + "Start": { + "Line": 8, + "Column": 3, + "Offset": 0 + }, + "End": { + "Line": 8, + "Column": 43, + "Offset": 0 + } + } + }, + { + "Kind": "no_secrets_in_bundle", + "Value": "", + "Code": "audit_bundle_secret", + "Span": { + "Start": { + "Line": 9, + "Column": 3, + "Offset": 0 + }, + "End": { + "Line": 9, + "Column": 31, + "Offset": 0 + } + } + }, + { + "Kind": "deny_raw_html_sinks", + "Value": "", + "Code": "audit_raw_html_sink", + "Span": { + "Start": { + "Line": 10, + "Column": 3, + "Offset": 0 + }, + "End": { + "Line": 10, + "Column": 16, + "Offset": 0 + } + } + }, + { + "Kind": "allow_raw_html", + "Value": "home:TrustedHTML", + "Code": "", + "Span": { + "Start": { + "Line": 11, + "Column": 3, + "Offset": 0 + }, + "End": { + "Line": 11, + "Column": 36, + "Offset": 0 + } + } + } + ], + "Span": { + "Start": { + "Line": 3, + "Column": 1, + "Offset": 0 + }, + "End": { + "Line": 3, + "Column": 38, + "Offset": 0 + } + } + } + ], + "tests": [ + { + "Name": "admin_forbidden", + "Body": "expect GET \"/admin\" as \"anonymous\" status 403", + "Span": { + "Start": { + "Line": 14, + "Column": 1, + "Offset": 0 + }, + "End": { + "Line": 14, + "Column": 23, + "Offset": 0 + } + } + } + ] +} diff --git a/internal/parser/testdata/golden/security.audit.gwdk b/internal/parser/testdata/golden/security.audit.gwdk new file mode 100644 index 00000000..3768a99c --- /dev/null +++ b/internal/parser/testdata/golden/security.audit.gwdk @@ -0,0 +1,16 @@ +package security + +policy admin extends "baseline.api" { + match "/admin/**" + require guard "role:admin" + deny public + require max_body "256kb" + require header "Content-Security-Policy" + require no_secrets_in_bundle + deny raw_html + allow raw_html "home:TrustedHTML" +} + +test admin_forbidden { + expect GET "/admin" as "anonymous" status 403 +} diff --git a/internal/project/config.go b/internal/project/config.go index 77e976e3..8b3b0f5c 100644 --- a/internal/project/config.go +++ b/internal/project/config.go @@ -483,6 +483,8 @@ func parseBuildConfig(expression ast.Expr) gowdk.BuildConfig { build.Head = parseHeadConfig(keyValue.Value) case "CSRF": build.CSRF = parseCSRFConfig(keyValue.Value) + case "SecurityHeaders": + build.SecurityHeaders = parseSecurityHeadersConfig(keyValue.Value) case "BodyLimits": build.BodyLimits = parseBodyLimitsConfig(keyValue.Value) case "AllowMissingBackend": @@ -498,6 +500,32 @@ func parseBuildConfig(expression ast.Expr) gowdk.BuildConfig { return build } +func parseSecurityHeadersConfig(expression ast.Expr) gowdk.SecurityHeadersConfig { + literal, ok := expression.(*ast.CompositeLit) + if !ok { + return gowdk.SecurityHeadersConfig{} + } + + var headers gowdk.SecurityHeadersConfig + for _, element := range literal.Elts { + keyValue, ok := element.(*ast.KeyValueExpr) + if !ok { + continue + } + key, ok := keyValue.Key.(*ast.Ident) + if !ok { + continue + } + switch key.Name { + case "Enabled": + headers.Enabled = parseBool(keyValue.Value) + case "Headers": + headers.Headers = parseStringMap(keyValue.Value) + } + } + return headers +} + func parseBodyLimitsConfig(expression ast.Expr) gowdk.BodyLimitsConfig { literal, ok := expression.(*ast.CompositeLit) if !ok { @@ -673,6 +701,30 @@ func parseBuildTarget(expression ast.Expr) gowdk.BuildTargetConfig { return target } +func parseStringMap(expression ast.Expr) map[string]string { + literal, ok := expression.(*ast.CompositeLit) + if !ok { + return nil + } + values := map[string]string{} + for _, element := range literal.Elts { + keyValue, ok := element.(*ast.KeyValueExpr) + if !ok { + continue + } + key := parseString(keyValue.Key) + value := parseString(keyValue.Value) + if key == "" { + continue + } + values[key] = value + } + if len(values) == 0 { + return nil + } + return values +} + func parseStylesheets(expression ast.Expr) []gowdk.Stylesheet { literal, ok := expression.(*ast.CompositeLit) if !ok { diff --git a/internal/project/config_test.go b/internal/project/config_test.go index 8c7962aa..2d74f226 100644 --- a/internal/project/config_test.go +++ b/internal/project/config_test.go @@ -64,6 +64,13 @@ var Config = gowdk.Config{ HeaderName: "X-Example-CSRF", Insecure: true, }, + SecurityHeaders: gowdk.SecurityHeadersConfig{ + Enabled: true, + Headers: map[string]string{ + "Content-Security-Policy": "default-src 'self'", + "X-Content-Type-Options": "nosniff", + }, + }, BodyLimits: gowdk.BodyLimitsConfig{ ActionBytes: 2097152, APIBytes: 524288, @@ -164,6 +171,9 @@ var Config = gowdk.Config{ if !config.Build.CSRF.Enabled || config.Build.CSRF.SecretEnv != "EXAMPLE_CSRF_SECRET" || config.Build.CSRF.CookieName != "__Host-example-csrf" || config.Build.CSRF.FieldName != "_example_csrf" || config.Build.CSRF.HeaderName != "X-Example-CSRF" || !config.Build.CSRF.Insecure { t.Fatalf("unexpected build csrf config: %#v", config.Build.CSRF) } + if !config.Build.SecurityHeaders.Enabled || config.Build.SecurityHeaders.Headers["Content-Security-Policy"] != "default-src 'self'" || config.Build.SecurityHeaders.Headers["X-Content-Type-Options"] != "nosniff" { + t.Fatalf("unexpected security headers config: %#v", config.Build.SecurityHeaders) + } if config.Build.BodyLimits.ActionBytes != 2097152 || config.Build.BodyLimits.APIBytes != 524288 { t.Fatalf("unexpected body limits config: %#v", config.Build.BodyLimits) } diff --git a/internal/safeasset/safeasset.go b/internal/safeasset/safeasset.go new file mode 100644 index 00000000..166d4c58 --- /dev/null +++ b/internal/safeasset/safeasset.go @@ -0,0 +1,60 @@ +// Package safeasset centralizes checks for files that must not be copied into +// generated public or embedded output. +package safeasset + +import ( + "path" + "path/filepath" + "strings" +) + +// UnsafeEmbeddedDirectory reports whether a directory should be skipped when +// copying generated output into a generated app. +func UnsafeEmbeddedDirectory(rel string) bool { + base := path.Base(filepath.ToSlash(rel)) + switch base { + case ".git", ".hg", ".svn", "node_modules", "tmp", "temp", ".tmp", "private", ".private", "secrets", ".secrets": + return true + default: + return false + } +} + +// UnsafeEmbeddedFile reports whether a file must not be embedded or served as +// generated static output. +func UnsafeEmbeddedFile(rel string) bool { + rel = filepath.ToSlash(rel) + base := path.Base(rel) + normalizedBase := strings.ToLower(base) + ext := path.Ext(normalizedBase) + switch { + case normalizedBase == ".env" || strings.HasPrefix(normalizedBase, ".env."): + return true + case normalizedBase == "gowdk-security.json": + return true + case normalizedBase == ".npmrc" || normalizedBase == ".netrc": + return true + case PrivateKeyFile(normalizedBase): + return true + case ext == ".map" || ext == ".gwdk" || ext == ".go": + return true + case ext == ".tmp" || ext == ".temp" || strings.HasSuffix(normalizedBase, "~"): + return true + case ext == ".key" || ext == ".pem" || ext == ".p12" || ext == ".pfx": + return true + case strings.HasSuffix(normalizedBase, ".swp") || strings.HasSuffix(normalizedBase, ".swo"): + return true + default: + return false + } +} + +// PrivateKeyFile reports common private key filenames without extension. +func PrivateKeyFile(base string) bool { + switch strings.ToLower(base) { + case "id_rsa", "id_dsa", "id_ecdsa", "id_ed25519": + return true + default: + return false + } +} diff --git a/internal/securitymanifest/manifest.go b/internal/securitymanifest/manifest.go index f7aa0e56..561b0478 100644 --- a/internal/securitymanifest/manifest.go +++ b/internal/securitymanifest/manifest.go @@ -14,11 +14,17 @@ package securitymanifest import ( "fmt" + "path/filepath" + "sort" + "strings" "github.com/cssbruno/gowdk" "github.com/cssbruno/gowdk/internal/compiler" "github.com/cssbruno/gowdk/internal/gwdkir" + "github.com/cssbruno/gowdk/internal/safeasset" + "github.com/cssbruno/gowdk/internal/securitytext" "github.com/cssbruno/gowdk/internal/source" + "github.com/cssbruno/gowdk/internal/view" ) // SchemaVersion is the gowdk-security.json schema version. @@ -75,13 +81,20 @@ type ContractEntry struct { } // FrontendSurface describes the build-time / client-facing security surface. -// Phase 1 populates UnguardedRoutes from route posture; the remaining fields are -// enriched by the frontend audits. +// Policy evaluation consumes these posture facts without deciding whether they +// are acceptable here. type FrontendSurface struct { - UnguardedRoutes []string `json:"unguardedRoutes"` - BundleSecrets []BundleLeak `json:"bundleSecrets"` - RawHTMLSinks []RawHTMLSink `json:"rawHtmlSinks"` - ConfiguredHeaders []string `json:"configuredHeaders"` + UnguardedRoutes []UnguardedRoute `json:"unguardedRoutes"` + BundleSecrets []BundleLeak `json:"bundleSecrets"` + RawHTMLSinks []RawHTMLSink `json:"rawHtmlSinks"` + ConfiguredHeaders []ConfiguredHeader `json:"configuredHeaders"` +} + +// UnguardedRoute records one client-visible route that relies on generated +// default-deny handling because the source declared no guard. +type UnguardedRoute struct { + Route string `json:"route"` + Source string `json:"source,omitempty"` } // BundleLeak records a secret-shaped value found in embedded output or @@ -99,6 +112,11 @@ type RawHTMLSink struct { Source string `json:"source"` } +// ConfiguredHeader records one header configured for generated runtime output. +type ConfiguredHeader struct { + Name string `json:"name"` +} + // Build projects validated IR into a SecurityManifest. It reuses // compiler.BuildRouteMetadataFromIR so the posture matches the CLI routes and // endpoints reports exactly. @@ -107,11 +125,12 @@ func Build(config gowdk.Config, ir gwdkir.Program) SecurityManifest { manifest := SecurityManifest{ Version: SchemaVersion, GeneratedFrom: "ir", - Frontend: FrontendSurface{ConfiguredHeaders: []string{}}, + Frontend: FrontendSurface{ConfiguredHeaders: configuredHeaders(config)}, } - var unguarded []string + var unguarded []UnguardedRoute for _, route := range metadata.Routes { + routeSource := sourceRef(route.Source, route.SourceSpan) entry := RouteEntry{ PageID: route.PageID, Route: route.Route, @@ -121,11 +140,11 @@ func Build(config gowdk.Config, ir gwdkir.Program) SecurityManifest { Guards: append([]string(nil), route.Guards...), Public: hasPublicGuard(route.Guards), DefaultDeny: len(route.Guards) == 0, - Source: sourceRef(route.Source, route.SourceSpan), + Source: routeSource, } manifest.Routes = append(manifest.Routes, entry) if entry.DefaultDeny { - unguarded = append(unguarded, route.Route) + unguarded = append(unguarded, UnguardedRoute{Route: route.Route, Source: routeSource}) } } @@ -154,8 +173,10 @@ func Build(config gowdk.Config, ir gwdkir.Program) SecurityManifest { } manifest.Frontend.UnguardedRoutes = unguarded + manifest.Frontend.BundleSecrets = bundleLeaks(ir) + manifest.Frontend.RawHTMLSinks = rawHTMLSinks(ir) if manifest.Frontend.UnguardedRoutes == nil { - manifest.Frontend.UnguardedRoutes = []string{} + manifest.Frontend.UnguardedRoutes = []UnguardedRoute{} } if manifest.Frontend.BundleSecrets == nil { manifest.Frontend.BundleSecrets = []BundleLeak{} @@ -175,6 +196,115 @@ func hasPublicGuard(guards []string) bool { return false } +func configuredHeaders(config gowdk.Config) []ConfiguredHeader { + if !config.Build.SecurityHeaders.Enabled || len(config.Build.SecurityHeaders.Headers) == 0 { + return []ConfiguredHeader{} + } + names := make([]string, 0, len(config.Build.SecurityHeaders.Headers)) + for name := range config.Build.SecurityHeaders.Headers { + name = strings.TrimSpace(name) + if name == "" { + continue + } + names = append(names, name) + } + sort.Strings(names) + headers := make([]ConfiguredHeader, 0, len(names)) + for _, name := range names { + headers = append(headers, ConfiguredHeader{Name: name}) + } + return headers +} + +func bundleLeaks(ir gwdkir.Program) []BundleLeak { + var leaks []BundleLeak + for _, asset := range ir.Assets { + switch { + case asset.Path != "" && safeasset.UnsafeEmbeddedFile(asset.Path): + leaks = append(leaks, BundleLeak{ + Source: sourceRef(asset.Source, asset.Span), + Kind: "unsafe-asset:" + filepath.Base(filepath.ToSlash(asset.Path)), + }) + case asset.Inline != "": + if kind, ok := securitytext.FirstSecretKind(asset.Inline); ok { + leaks = append(leaks, BundleLeak{ + Source: sourceRef(asset.Source, asset.Span), + Kind: "inline-asset:" + kind, + }) + } + } + } + for _, page := range ir.Pages { + if !page.Blocks.Build { + continue + } + if kind, ok := securitytext.FirstSecretKind(page.Blocks.BuildBody); ok { + leaks = append(leaks, BundleLeak{ + Source: sourceRef(page.Source, page.Blocks.Spans.Build), + Kind: "build-data:" + kind, + }) + } + } + return leaks +} + +func rawHTMLSinks(ir gwdkir.Program) []RawHTMLSink { + var sinks []RawHTMLSink + for _, template := range ir.Templates { + nodes, err := view.Parse(template.Body) + if err != nil { + continue + } + sinks = append(sinks, rawHTMLSinksForNodes(nodes, template)...) + } + return sinks +} + +func rawHTMLSinksForNodes(nodes []view.Node, template gwdkir.Template) []RawHTMLSink { + var sinks []RawHTMLSink + var walk func([]view.Node) + walk = func(nodes []view.Node) { + for _, node := range nodes { + switch typed := node.(type) { + case view.Element: + for _, attr := range typed.Attrs { + if attr.Name != "g:html" { + continue + } + sinks = append(sinks, RawHTMLSink{ + OwnerKind: string(template.OwnerKind), + OwnerID: template.OwnerID, + Field: strings.TrimSpace(attr.Value), + Source: sourceRef(template.Source, templateOffsetSpan(template, attr.Start)), + }) + } + walk(typed.Children) + case view.ComponentCall: + walk(typed.Children) + } + } + } + walk(nodes) + return sinks +} + +func templateOffsetSpan(template gwdkir.Template, offset int) source.SourceSpan { + line := template.BodyStart.Line + if line <= 0 { + line = template.Span.Start.Line + } + if line <= 0 { + return template.Span + } + if offset > 0 && offset <= len(template.Body) { + line += strings.Count(template.Body[:offset], "\n") + } + return source.SourceSpan{ + Start: source.SourcePosition{Line: line, Column: 1}, + End: source.SourcePosition{Line: line, Column: 2}, + } +} + func endpointID(endpoint compiler.EndpointBinding) string { for _, candidate := range []string{endpoint.Symbol, endpoint.Contract.Name, endpoint.Handler, endpoint.PageID} { if candidate != "" { diff --git a/internal/securitymanifest/manifest_test.go b/internal/securitymanifest/manifest_test.go index 83b0cc99..a0587c39 100644 --- a/internal/securitymanifest/manifest_test.go +++ b/internal/securitymanifest/manifest_test.go @@ -50,7 +50,7 @@ func TestBuildProjectsRoutesAndEndpoints(t *testing.T) { t.Fatalf("source with a span line should be file:line, got %q", home.Source) } - if got := manifest.Frontend.UnguardedRoutes; len(got) != 1 || got[0] != "/draft" { + if got := manifest.Frontend.UnguardedRoutes; len(got) != 1 || got[0].Route != "/draft" { t.Fatalf("expected /draft in unguardedRoutes, got %#v", got) } @@ -73,6 +73,51 @@ func TestBuildProjectsRoutesAndEndpoints(t *testing.T) { } } +func TestBuildPopulatesFrontendAuditSurface(t *testing.T) { + config := gowdk.Config{Build: gowdk.BuildConfig{SecurityHeaders: gowdk.SecurityHeadersConfig{ + Enabled: true, + Headers: map[string]string{ + "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": "default-src 'self'", + }, + }}} + ir := gwdkir.Program{ + Pages: []gwdkir.Page{{ + Source: "home.page.gwdk", + ID: "home", + Blocks: gwdkir.Blocks{ + Build: true, + BuildBody: `=> { api_key: "live_sk_abc123" }`, + Spans: gwdkir.BlockSpans{Build: source.SourceSpan{Start: source.SourcePosition{Line: 9, Column: 1}}}, + }, + }}, + Assets: []gwdkir.Asset{ + {Kind: gwdkir.AssetFile, Source: "card.cmp.gwdk", Path: ".env", Span: source.SourceSpan{Start: source.SourcePosition{Line: 4, Column: 1}}}, + {Kind: gwdkir.AssetJS, Source: "home.page.gwdk", Inline: `const token = "secret";`, Span: source.SourceSpan{Start: source.SourcePosition{Line: 5, Column: 1}}}, + }, + Templates: []gwdkir.Template{{ + OwnerKind: gwdkir.SourcePage, + OwnerID: "home", + Source: "home.page.gwdk", + Body: `
`, + BodyStart: source.SourcePosition{Line: 12, Column: 1}, + Span: source.SourceSpan{Start: source.SourcePosition{Line: 11, Column: 1}}, + }}, + } + + manifest := Build(config, ir) + + if got := manifest.Frontend.ConfiguredHeaders; len(got) != 2 || got[0].Name != "Content-Security-Policy" || got[1].Name != "X-Content-Type-Options" { + t.Fatalf("expected sorted configured headers, got %#v", got) + } + if got := manifest.Frontend.BundleSecrets; len(got) != 3 { + t.Fatalf("expected three bundle secret findings, got %#v", got) + } + if got := manifest.Frontend.RawHTMLSinks; len(got) != 1 || got[0].OwnerID != "home" || got[0].Field != "TrustedHTML" || got[0].Source != "home.page.gwdk:12" { + t.Fatalf("expected raw HTML sink source, got %#v", got) + } +} + func TestBuildHonorsConfiguredBodyLimits(t *testing.T) { config := gowdk.Config{Build: gowdk.BuildConfig{BodyLimits: gowdk.BodyLimitsConfig{ActionBytes: 256 << 10, APIBytes: 512 << 10}}} ir := gwdkir.Program{ diff --git a/internal/securitytext/securitytext.go b/internal/securitytext/securitytext.go new file mode 100644 index 00000000..dc1f35f1 --- /dev/null +++ b/internal/securitytext/securitytext.go @@ -0,0 +1,55 @@ +// Package securitytext contains conservative secret-like text detection and +// redaction helpers shared by runtime logs and audit posture projection. +package securitytext + +import "regexp" + +const RedactionMask = "[REDACTED]" + +type rule struct { + kind string + pattern *regexp.Regexp + replacement string +} + +// Rules run in order. The DSN and Bearer/Basic scheme rules run before the +// generic key=value rule so an "Authorization: Bearer " string has its +// token masked by the scheme rule rather than half-consumed by the key rule. +var rules = []rule{ + { + kind: "dsn-password", + pattern: regexp.MustCompile(`([a-zA-Z][a-zA-Z0-9+.-]*://[^:/@\s]+:)[^@/\s]+(@)`), + replacement: `${1}` + RedactionMask + `${2}`, + }, + { + kind: "authorization-token", + pattern: regexp.MustCompile(`(?i)\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}`), + replacement: `${1} ` + RedactionMask, + }, + { + kind: "secret-key-value", + pattern: regexp.MustCompile(`(?i)\b(password|passwd|pwd|secret|token|_?gowdk[_-]?csrf|_?csrf(?:[_-]?token)?|cookie|set-cookie|auth[_-]?token|session(?:[_-]?id)?|jwt|api[_-]?key|access(?:[_-]?(?:key|token))?|refresh[_-]?token|id[_-]?token|client[_-]?secret|private[_-]?key)\b(\s*[:=]\s*)("?)[^\s"',;)]+`), + replacement: `${1}${2}${3}` + RedactionMask, + }, +} + +// RedactSecrets masks values that commonly carry credentials. +func RedactSecrets(message string) string { + if message == "" { + return message + } + for _, rule := range rules { + message = rule.pattern.ReplaceAllString(message, rule.replacement) + } + return message +} + +// FirstSecretKind returns the first secret-like rule kind matched in text. +func FirstSecretKind(text string) (string, bool) { + for _, rule := range rules { + if rule.pattern.MatchString(text) { + return rule.kind, true + } + } + return "", false +} diff --git a/runtime/app/app.go b/runtime/app/app.go index c000d3e9..db5f7c58 100644 --- a/runtime/app/app.go +++ b/runtime/app/app.go @@ -39,17 +39,18 @@ type Identity struct { // Handler serves embedded generated output plus optional action and SSR hooks. type Handler struct { - Root fs.FS - Identity Identity - Assets asset.Manifest - Backend HandlerFunc - Action HandlerFunc - API HandlerFunc - CSRF CSRFTokenSource - ErrorPages ErrorPages - Metrics *Metrics - SSRExact HandlerFunc - SSRDynamic HandlerFunc + Root fs.FS + Identity Identity + SecurityHeaders map[string]string + Assets asset.Manifest + Backend HandlerFunc + Action HandlerFunc + API HandlerFunc + CSRF CSRFTokenSource + ErrorPages ErrorPages + Metrics *Metrics + SSRExact HandlerFunc + SSRDynamic HandlerFunc // Denied holds concrete page routes that declared no guard. Such a page is // not public by default: its GET/HEAD route returns 403 until the author @@ -115,6 +116,7 @@ func (handler Handler) ServeHTTP(response http.ResponseWriter, request *http.Req defer cancel() request = request.WithContext(ctx) } + handler.writeSecurityHeaders(response) handler.writeIdentityHeaders(response) if len(handler.ErrorPages.NotFound) > 0 || len(handler.ErrorPages.InternalServerError) > 0 || len(handler.ErrorPages.Custom) > 0 { request = request.WithContext(withErrorPages(request.Context(), handler.ErrorPages)) @@ -548,6 +550,16 @@ func (handler Handler) writeIdentityHeaders(response http.ResponseWriter) { response.Header().Set("X-GOWDK-Instance-ID", handler.Identity.InstanceID) } +func (handler Handler) writeSecurityHeaders(response http.ResponseWriter) { + for name, value := range handler.SecurityHeaders { + name = strings.TrimSpace(name) + if name == "" { + continue + } + response.Header().Set(name, value) + } +} + func (handler Handler) health(response http.ResponseWriter) { response.Header().Set("Content-Type", "application/json") payload := map[string]any{ diff --git a/runtime/app/app_test.go b/runtime/app/app_test.go index 21544d31..cacf8fc2 100644 --- a/runtime/app/app_test.go +++ b/runtime/app/app_test.go @@ -44,6 +44,34 @@ func TestHandlerServesAppIndexAndIdentityHeaders(t *testing.T) { } } +func TestHandlerWritesConfiguredSecurityHeaders(t *testing.T) { + handler := Handler{ + Root: fstest.MapFS{ + "index.html": {Data: []byte("
Home
")}, + }, + Identity: Identity{AppID: "clinic", ModuleName: "frontend", InstanceID: "frontend-1"}, + Assets: asset.Manifest{Version: 1, Files: map[string]string{}}, + SecurityHeaders: map[string]string{ + "Content-Security-Policy": "default-src 'self'", + "X-Frame-Options": "DENY", + }, + } + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/", nil) + + handler.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("unexpected status: %d", recorder.Code) + } + if got := recorder.Header().Get("Content-Security-Policy"); got != "default-src 'self'" { + t.Fatalf("unexpected CSP header: %q", got) + } + if got := recorder.Header().Get("X-Frame-Options"); got != "DENY" { + t.Fatalf("unexpected frame header: %q", got) + } +} + func TestHandlerDeniesGuardlessRouteWith403(t *testing.T) { handler := Handler{ Root: fstest.MapFS{ diff --git a/runtime/app/redact.go b/runtime/app/redact.go index 5a269a45..47ef6166 100644 --- a/runtime/app/redact.go +++ b/runtime/app/redact.go @@ -1,6 +1,6 @@ package app -import "regexp" +import "github.com/cssbruno/gowdk/internal/securitytext" // redactSecrets masks values that commonly carry credentials so a recovered // panic or error can be logged without leaking secrets into operator logs. @@ -8,39 +8,5 @@ import "regexp" // over letting a real secret through, and it never touches the HTTP response // (which already returns a generic message). func redactSecrets(message string) string { - if message == "" { - return message - } - for _, rule := range redactionRules { - message = rule.pattern.ReplaceAllString(message, rule.replacement) - } - return message -} - -const redactionMask = "[REDACTED]" - -// Rules run in order. The DSN and Bearer/Basic scheme rules run before the -// generic key=value rule so an "Authorization: Bearer " string has its -// token masked by the scheme rule rather than half-consumed by the key rule. -var redactionRules = []struct { - pattern *regexp.Regexp - replacement string -}{ - // scheme://user:password@host (DB DSNs, connection strings) - { - pattern: regexp.MustCompile(`([a-zA-Z][a-zA-Z0-9+.-]*://[^:/@\s]+:)[^@/\s]+(@)`), - replacement: `${1}` + redactionMask + `${2}`, - }, - // Authorization header style: "Bearer " / "Basic " - { - pattern: regexp.MustCompile(`(?i)\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}`), - replacement: `${1} ` + redactionMask, - }, - // key=value / key: value where key names a secret-bearing header, cookie, - // form field, query param, or credential. Requires an explicit : or = - // separator so it does not swallow following words by whitespace. - { - pattern: regexp.MustCompile(`(?i)\b(password|passwd|pwd|secret|token|_?gowdk[_-]?csrf|_?csrf(?:[_-]?token)?|cookie|set-cookie|auth[_-]?token|session(?:[_-]?id)?|jwt|api[_-]?key|access(?:[_-]?(?:key|token))?|refresh[_-]?token|id[_-]?token|client[_-]?secret|private[_-]?key)\b(\s*[:=]\s*)("?)[^\s"',;)]+`), - replacement: `${1}${2}${3}` + redactionMask, - }, + return securitytext.RedactSecrets(message) } diff --git a/runtime/testkit/testkit.go b/runtime/testkit/testkit.go new file mode 100644 index 00000000..37e3b2ba --- /dev/null +++ b/runtime/testkit/testkit.go @@ -0,0 +1,86 @@ +// Package testkit provides small helpers for generated runtime integration +// tests. +package testkit + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// Scenario describes one HTTP expectation against a generated app handler. +type Scenario struct { + Name string + Method string + Path string + Body string + Headers map[string]string + WantStatus int + WantHeader map[string]string +} + +// Run executes scenarios against handler. +func Run(t testing.TB, handler http.Handler, scenarios []Scenario) { + t.Helper() + for _, scenario := range scenarios { + response := Response(handler, scenario) + if scenario.WantStatus != 0 && response.Code != scenario.WantStatus { + t.Fatalf("%s: expected status %d, got %d with body %q", scenarioName(scenario), scenario.WantStatus, response.Code, response.Body.String()) + } + for name, want := range scenario.WantHeader { + if got := response.Header().Get(name); got != want { + t.Fatalf("%s: expected header %s=%q, got %q", scenarioName(scenario), name, want, got) + } + } + } +} + +// AssertStatus checks one request's response status. +func AssertStatus(t testing.TB, handler http.Handler, method, requestPath, body string, want int) { + t.Helper() + Run(t, handler, []Scenario{{ + Name: method + " " + requestPath, + Method: method, + Path: requestPath, + Body: body, + WantStatus: want, + }}) +} + +// AssertHeader checks one response header value. +func AssertHeader(t testing.TB, handler http.Handler, method, requestPath, name, want string) { + t.Helper() + Run(t, handler, []Scenario{{ + Name: method + " " + requestPath, + Method: method, + Path: requestPath, + WantHeader: map[string]string{name: want}, + }}) +} + +// Response executes one scenario and returns the recorder for custom checks. +func Response(handler http.Handler, scenario Scenario) *httptest.ResponseRecorder { + method := strings.TrimSpace(scenario.Method) + if method == "" { + method = http.MethodGet + } + requestPath := strings.TrimSpace(scenario.Path) + if requestPath == "" { + requestPath = "/" + } + request := httptest.NewRequest(method, requestPath, strings.NewReader(scenario.Body)) + for name, value := range scenario.Headers { + request.Header.Set(name, value) + } + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + return response +} + +func scenarioName(scenario Scenario) string { + if strings.TrimSpace(scenario.Name) != "" { + return scenario.Name + } + return strings.TrimSpace(scenario.Method + " " + scenario.Path) +} diff --git a/runtime/testkit/testkit_test.go b/runtime/testkit/testkit_test.go new file mode 100644 index 00000000..56e30cd7 --- /dev/null +++ b/runtime/testkit/testkit_test.go @@ -0,0 +1,31 @@ +package testkit + +import ( + "net/http" + "testing" +) + +func TestRunChecksStatusAndHeaders(t *testing.T) { + handler := http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.Header().Set("X-Test", "ok") + response.WriteHeader(http.StatusAccepted) + }) + + Run(t, handler, []Scenario{{ + Name: "accepted", + Method: http.MethodPost, + Path: "/submit", + WantStatus: http.StatusAccepted, + WantHeader: map[string]string{"X-Test": "ok"}, + }}) +} + +func TestAssertHelpers(t *testing.T) { + handler := http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.Header().Set("X-Frame-Options", "DENY") + response.WriteHeader(http.StatusNoContent) + }) + + AssertStatus(t, handler, http.MethodGet, "/", "", http.StatusNoContent) + AssertHeader(t, handler, http.MethodGet, "/", "X-Frame-Options", "DENY") +} From 4b288fcbe529d0ae3814c360251135bfd68bfde2 Mon Sep 17 00:00:00 2001 From: cssbruno Date: Sat, 13 Jun 2026 22:57:07 -0300 Subject: [PATCH 4/6] docs(audit): document audit policy syntax --- docs/language/README.md | 6 ++- docs/language/audit.md | 106 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 docs/language/audit.md diff --git a/docs/language/README.md b/docs/language/README.md index 4aca5e4f..3c22c2cf 100644 --- a/docs/language/README.md +++ b/docs/language/README.md @@ -44,6 +44,7 @@ component contract and inline package-go-block slices. route output. - `actions.md`: action status and planned typed action behavior. - `api.md`: API block status and planned handler behavior. +- `audit.md`: `*.audit.gwdk` policy and generated audit test syntax. - `partials.md`: partial update status and planned fragment behavior. - `forms.md`: form submission, progressive enhancement, validation, and invalidation boundaries. @@ -67,5 +68,6 @@ guard public The page ID derives from the filename unless `page` is present. Component files are supported as explicit or discovered `gowdk build` inputs -with `component`. Layout files are also supported. Separate island file kinds -are planned. +with `component`. Layout files are also supported. `*.audit.gwdk` files are a +separate audit policy/test kind consumed by `gowdk audit`; they do not generate +pages. Separate island file kinds are planned. diff --git a/docs/language/audit.md b/docs/language/audit.md new file mode 100644 index 00000000..344f7efe --- /dev/null +++ b/docs/language/audit.md @@ -0,0 +1,106 @@ +# Audit Policy Files + +`*.audit.gwdk` files declare security policy and runtime audit expectations. +They are discovered with normal `.gwdk` inputs, lowered into IR, and consumed by +`gowdk audit`; they do not generate pages, routes, or browser assets. + +```gwdk +package security + +policy admin extends "baseline.frontend" { + match "frontend" + require header "Content-Security-Policy" + deny raw_html +} + +policy admin_routes { + match "/admin/**" + require guard "role:admin" +} + +test headers { + expect header "Content-Security-Policy" "default-src 'self'" +} + +test admin_denied { + expect GET "/admin" as "anonymous" status 403 +} +``` + +## Policies + +Use `policy {}` for a named policy. A policy can extend one or more +other policies: + +```gwdk +policy browser_hardening extends "baseline.frontend" { + match "frontend" + require header "X-Frame-Options" +} +``` + +Selectors are string literals: + +- Route globs: `"/admin/**"`, `"/settings/*"`, or `"/"`. +- Endpoint selectors: `"act:*"`, `"api:*"`, `"fragment:*"`, `"command:*"`, + and `"query:*"`. +- Frontend selector: `"frontend"`. + +`match ""` and `apply to ""` are equivalent. + +## Rules + +Supported rule forms: + +```gwdk +require csrf +require guard "role:admin" +require header "Content-Security-Policy" +require max_body "256kb" +require no_secrets_in_bundle +deny public +deny raw_html +allow raw_html "home:body" +``` + +Add `as ` to override the finding code for a rule: + +```gwdk +require guard "permission:patients.read" as "audit_required_guard_missing" +``` + +Raw HTML allowlist values match either the exact source reference reported by +`gowdk audit` or `:`. + +## Tests + +`test {}` blocks become generated Go tests. `gowdk audit --emit-tests` writes a +readable `gowdk_audit_test.go`; `gowdk audit --run` generates and runs the same +source with `go test`. + +Supported expectations: + +```gwdk +expect GET "/dashboard" status 403 +expect GET "/dashboard" as "role:admin" status 200 +expect header "X-Frame-Options" "DENY" +``` + +Status expectations drive the generated handler through `runtime/testkit`. +Header expectations check the runtime health endpoint so header policy can be +verified without depending on a specific page route. + +## Built-In Baseline + +`gowdk audit` always composes declared policies with the built-in baseline. +Built-in policy names include: + +- `"baseline.actions"` +- `"baseline.fragments"` +- `"baseline.api"` +- `"baseline.contract_commands"` +- `"baseline.contract_queries"` +- `"baseline.frontend"` + +A declared policy with the same name intentionally replaces that built-in slice. +Otherwise declared policies are appended and can extend the built-ins. From 0421280555195e49d70cd70156645293083fe38c Mon Sep 17 00:00:00 2001 From: cssbruno Date: Sat, 13 Jun 2026 22:59:51 -0300 Subject: [PATCH 5/6] feat(audit): support audit test actors --- internal/appgen/appgen_test.go | 41 ++++++++++++++++++++ internal/appgen/audit_tests.go | 70 +++++++++++++++++++++++++++++++++- 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/internal/appgen/appgen_test.go b/internal/appgen/appgen_test.go index 09c62017..71da3c78 100644 --- a/internal/appgen/appgen_test.go +++ b/internal/appgen/appgen_test.go @@ -205,6 +205,47 @@ func TestGenerateWritesAuditIntegrationTest(t *testing.T) { } } +func TestGeneratedAuditTestInstallsNativeRBACActorProvider(t *testing.T) { + source, err := GeneratedAuditTestSource(Options{ + SSR: []SSRRoute{{ + Route: "/admin", + Guards: []string{"role:admin"}, + }}, + IR: &gwdkir.Program{ + Routes: []gwdkir.Route{{ + Kind: gwdkir.RouteSSR, + Method: "GET", + Path: "/admin", + PageID: "admin", + Render: gowdk.SSR, + Guards: []string{"role:admin"}, + }}, + AuditSpecs: []gwdkir.AuditSpec{{ + Source: "security.audit.gwdk", + Tests: []gwdkir.AuditTest{{ + Name: "admin", + Body: `expect GET "/admin" as "role:admin" status 200`, + }}, + }}, + }, + }) + if err != nil { + t.Fatal(err) + } + payload := string(source) + for _, expected := range []string{ + `gowdkauth "github.com/cssbruno/gowdk/runtime/auth"`, + `"strings"`, + `RegisterAuthProvider(gowdkauth.ProviderFunc`, + `strings.TrimPrefix(actor, "role:")`, + `"X-GOWDK-Audit-Actor": "role:admin"`, + } { + if !strings.Contains(payload, expected) { + t.Fatalf("expected generated audit test to contain %q:\n%s", expected, payload) + } + } +} + func TestGeneratePreservesUnchangedFilesAndRemovesStaleSPAFiles(t *testing.T) { root := t.TempDir() outputDir := filepath.Join(root, "dist") diff --git a/internal/appgen/audit_tests.go b/internal/appgen/audit_tests.go index 8239b8f0..6bf5371f 100644 --- a/internal/appgen/audit_tests.go +++ b/internal/appgen/audit_tests.go @@ -26,6 +26,7 @@ type auditScenario struct { Name string Method string Path string + Actor string WantStatus int WantHeader map[string]string } @@ -60,16 +61,21 @@ func auditTestSource(packageName string, mode auditTestMode, config gowdk.Config if len(scenarios) == 0 { return nil, nil } + usesActor := auditScenariosUseActor(scenarios) + installAuthProvider := mode == auditTestGeneratedApp && usesActor && auditManifestUsesNativeGuards(manifest) var builder strings.Builder fmt.Fprintf(&builder, "package %s\n\n", packageName) - writeAuditTestImports(&builder, mode) + writeAuditTestImports(&builder, mode, installAuthProvider) builder.WriteString("\n") builder.WriteString("func TestGOWDKAuditGeneratedSecurityPosture(t *testing.T) {\n") switch mode { case auditTestGeneratedApp: builder.WriteString("\thandler, err := Handler()\n") builder.WriteString("\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n") + if installAuthProvider { + writeGeneratedAuditAuthProvider(&builder) + } case auditTestStandalone: writeStandaloneAuditHandler(&builder, config, manifest) } @@ -87,14 +93,20 @@ func auditTestSource(packageName string, mode auditTestMode, config gowdk.Config return formatted, nil } -func writeAuditTestImports(builder *strings.Builder, mode auditTestMode) { +func writeAuditTestImports(builder *strings.Builder, mode auditTestMode, usesActor bool) { builder.WriteString("import (\n") builder.WriteString("\t\"net/http\"\n") + if mode == auditTestGeneratedApp && usesActor { + builder.WriteString("\t\"strings\"\n") + } builder.WriteString("\t\"testing\"\n") if mode == auditTestStandalone { builder.WriteString("\t\"testing/fstest\"\n") } builder.WriteString("\n") + if mode == auditTestGeneratedApp && usesActor { + builder.WriteString("\tgowdkauth \"github.com/cssbruno/gowdk/runtime/auth\"\n") + } if mode == auditTestStandalone { builder.WriteString("\tgowdkruntime \"github.com/cssbruno/gowdk/runtime/app\"\n") builder.WriteString("\truntimeasset \"github.com/cssbruno/gowdk/runtime/asset\"\n") @@ -103,6 +115,38 @@ func writeAuditTestImports(builder *strings.Builder, mode auditTestMode) { builder.WriteString(")\n") } +func auditScenariosUseActor(scenarios []auditScenario) bool { + for _, scenario := range scenarios { + if strings.TrimSpace(scenario.Actor) != "" { + return true + } + } + return false +} + +func auditManifestUsesNativeGuards(manifest securitymanifest.SecurityManifest) bool { + for _, route := range manifest.Routes { + if auditGuardsUseNativeRBAC(route.Guards) { + return true + } + } + for _, endpoint := range manifest.Endpoints { + if auditGuardsUseNativeRBAC(endpoint.Guards) { + return true + } + } + return false +} + +func auditGuardsUseNativeRBAC(guards []string) bool { + for _, guard := range guards { + if strings.HasPrefix(guard, "role:") || strings.HasPrefix(guard, "permission:") { + return true + } + } + return false +} + func auditTestScenarios(config gowdk.Config, manifest securitymanifest.SecurityManifest, specs []gwdkir.AuditSpec) ([]auditScenario, error) { var scenarios []auditScenario routes := append([]securitymanifest.RouteEntry(nil), manifest.Routes...) @@ -223,6 +267,7 @@ func auditDeclaredTestScenarios(specs []gwdkir.AuditSpec) ([]auditScenario, erro Name: name, Method: strings.ToUpper(statusMatch[1]), Path: statusMatch[2], + Actor: statusMatch[3], WantStatus: status, }) continue @@ -245,6 +290,22 @@ func auditDeclaredTestScenarios(specs []gwdkir.AuditSpec) ([]auditScenario, erro return scenarios, nil } +func writeGeneratedAuditAuthProvider(builder *strings.Builder) { + builder.WriteString("\tRegisterAuthProvider(gowdkauth.ProviderFunc(func(request *http.Request) (*gowdkauth.Principal, error) {\n") + builder.WriteString("\t\tactor := strings.TrimSpace(request.Header.Get(\"X-GOWDK-Audit-Actor\"))\n") + builder.WriteString("\t\tswitch {\n") + builder.WriteString("\t\tcase actor == \"\" || actor == \"anonymous\":\n") + builder.WriteString("\t\t\treturn nil, nil\n") + builder.WriteString("\t\tcase strings.HasPrefix(actor, \"role:\"):\n") + builder.WriteString("\t\t\treturn &gowdkauth.Principal{ID: \"audit\", Roles: []string{strings.TrimPrefix(actor, \"role:\")}}, nil\n") + builder.WriteString("\t\tcase strings.HasPrefix(actor, \"permission:\"):\n") + builder.WriteString("\t\t\treturn &gowdkauth.Principal{ID: \"audit\", Permissions: []string{strings.TrimPrefix(actor, \"permission:\")}}, nil\n") + builder.WriteString("\t\tdefault:\n") + builder.WriteString("\t\t\treturn &gowdkauth.Principal{ID: \"audit\", Roles: []string{actor}}, nil\n") + builder.WriteString("\t\t}\n") + builder.WriteString("\t}))\n") +} + func writeStandaloneAuditHandler(builder *strings.Builder, config gowdk.Config, manifest securitymanifest.SecurityManifest) { builder.WriteString("\thandler := gowdkruntime.Handler{\n") builder.WriteString("\t\tRoot: fstest.MapFS{\n") @@ -338,6 +399,11 @@ func writeAuditScenario(builder *strings.Builder, scenario auditScenario) { fmt.Fprintf(builder, "\t\t\tName: %s,\n", strconv.Quote(scenario.Name)) fmt.Fprintf(builder, "\t\t\tMethod: %s,\n", auditMethodExpr(scenario.Method)) fmt.Fprintf(builder, "\t\t\tPath: %s,\n", strconv.Quote(path.Clean("/"+scenario.Path))) + if strings.TrimSpace(scenario.Actor) != "" { + builder.WriteString("\t\t\tHeaders: map[string]string{\n") + fmt.Fprintf(builder, "\t\t\t\t%s: %s,\n", strconv.Quote("X-GOWDK-Audit-Actor"), strconv.Quote(strings.TrimSpace(scenario.Actor))) + builder.WriteString("\t\t\t},\n") + } if scenario.WantStatus != 0 { fmt.Fprintf(builder, "\t\t\tWantStatus: %s,\n", auditStatusExpr(scenario.WantStatus)) } From 1fa559631fa68867a80802043c07c61497974d67 Mon Sep 17 00:00:00 2001 From: cssbruno Date: Sun, 14 Jun 2026 00:15:10 -0300 Subject: [PATCH 6/6] fix(audit): correct client-route severity, deduplicate findings, harden test fidelity Review follow-ups for the M8 declarative audit slice: - audit_client_route_unguarded: rewrite the registry summary (it claimed the route was "not covered" by default-deny, the opposite of when it fires) and downgrade error -> warning so it matches missing_page_guard for the same guardless condition. A default-deny route is safe under the runtime handler; the static-export caveat is a warning, not a CI-failing hole. Clarify the gowdk explain detail to describe the runtime-gate / static-export boundary. - Remediation honesty: the "waive the rule with a documented reason" / "audit policy waiver" guidance referenced a feature with no syntax. Point all four sites at the only real mechanism (override the matching baseline policy in a *.audit.gwdk file). - gowdk audit --run / --emit-tests: the standalone harness installs no auth provider and cannot enforce role/permission guards, so it now rejects declared test actor expectations instead of emitting a test that passes or fails for the wrong reason. Actors remain supported in the generated-app audit test that runs against the real guard pipeline. Document the boundary in audit.md. - Deduplicate findings that are identical except for the raising policy, so a policy that extends baseline.frontend no longer reports the same sink twice. - require csrf now leaves its diagnostic code unset in the parser; the engine resolves audit_command_missing_csrf vs audit_action_missing_csrf per matched endpoint kind, while an explicit `as ` still wins. - Generated-app audit test captures and restores authProvider via t.Cleanup so the RBAC actor override no longer leaks into sibling tests. Tests: kind-resolved CSRF code, cross-policy dedupe, standalone actor rejection. --- docs/language/audit.md | 7 +++++ internal/appgen/appgen_test.go | 39 ++++++++++++++++++++++++++ internal/appgen/audit_tests.go | 19 ++++++++++--- internal/auditspec/engine.go | 38 +++++++++++++++++++++++-- internal/auditspec/engine_test.go | 46 +++++++++++++++++++++++++++++++ internal/diagnostics/explain.go | 8 +++--- internal/diagnostics/registry.go | 2 +- internal/parser/audit.go | 4 ++- 8 files changed, 150 insertions(+), 13 deletions(-) diff --git a/docs/language/audit.md b/docs/language/audit.md index 344f7efe..18190cb4 100644 --- a/docs/language/audit.md +++ b/docs/language/audit.md @@ -90,6 +90,13 @@ Status expectations drive the generated handler through `runtime/testkit`. Header expectations check the runtime health endpoint so header policy can be verified without depending on a specific page route. +Actor expectations (`as "role:..."` / `as "permission:..."`) require the +generated-app audit test that `gowdk build` emits, because only that test runs +against the real guard pipeline. `gowdk audit --emit-tests` and `--run` build a +standalone harness that models static serving, default-deny, and headers but +installs no auth provider, so they reject actor expectations rather than pass or +fail them for the wrong reason. + ## Built-In Baseline `gowdk audit` always composes declared policies with the built-in baseline. diff --git a/internal/appgen/appgen_test.go b/internal/appgen/appgen_test.go index 71da3c78..04e546b4 100644 --- a/internal/appgen/appgen_test.go +++ b/internal/appgen/appgen_test.go @@ -18,6 +18,7 @@ import ( "github.com/cssbruno/gowdk/internal/compiler" "github.com/cssbruno/gowdk/internal/gwdkanalysis" "github.com/cssbruno/gowdk/internal/gwdkir" + "github.com/cssbruno/gowdk/internal/securitymanifest" "github.com/cssbruno/gowdk/internal/source" ) @@ -205,6 +206,44 @@ func TestGenerateWritesAuditIntegrationTest(t *testing.T) { } } +func TestStandaloneAuditTestRejectsActorScenarios(t *testing.T) { + specs := []gwdkir.AuditSpec{{ + Source: "security.audit.gwdk", + Tests: []gwdkir.AuditTest{{ + Name: "admin", + Body: `expect GET "/admin" as "role:admin" status 200`, + }}, + }} + // The standalone harness cannot enforce role/permission guards, so emitting + // an actor scenario for gowdk audit --run must fail loudly rather than + // produce a test that passes or fails for the wrong reason. + if _, err := StandaloneAuditTestSource(gowdk.Config{}, securitymanifest.SecurityManifest{}, specs); err == nil { + t.Fatal("expected standalone audit emit to reject actor scenarios") + } + // The generated-app path runs against the real guard pipeline, so the same + // actor scenario is supported there. + source, err := GeneratedAuditTestSource(Options{ + SSR: []SSRRoute{{Route: "/admin", Guards: []string{"role:admin"}}}, + IR: &gwdkir.Program{ + Routes: []gwdkir.Route{{ + Kind: gwdkir.RouteSSR, + Method: "GET", + Path: "/admin", + PageID: "admin", + Render: gowdk.SSR, + Guards: []string{"role:admin"}, + }}, + AuditSpecs: specs, + }, + }) + if err != nil { + t.Fatalf("expected generated-app actor test to succeed: %v", err) + } + if !strings.Contains(string(source), `"X-GOWDK-Audit-Actor": "role:admin"`) { + t.Fatalf("expected generated-app actor scenario, got:\n%s", source) + } +} + func TestGeneratedAuditTestInstallsNativeRBACActorProvider(t *testing.T) { source, err := GeneratedAuditTestSource(Options{ SSR: []SSRRoute{{ diff --git a/internal/appgen/audit_tests.go b/internal/appgen/audit_tests.go index 6bf5371f..ac0aba3a 100644 --- a/internal/appgen/audit_tests.go +++ b/internal/appgen/audit_tests.go @@ -54,7 +54,7 @@ func StandaloneAuditTestSource(config gowdk.Config, manifest securitymanifest.Se } func auditTestSource(packageName string, mode auditTestMode, config gowdk.Config, manifest securitymanifest.SecurityManifest, specs []gwdkir.AuditSpec) ([]byte, error) { - scenarios, err := auditTestScenarios(config, manifest, specs) + scenarios, err := auditTestScenarios(mode, config, manifest, specs) if err != nil { return nil, err } @@ -147,7 +147,7 @@ func auditGuardsUseNativeRBAC(guards []string) bool { return false } -func auditTestScenarios(config gowdk.Config, manifest securitymanifest.SecurityManifest, specs []gwdkir.AuditSpec) ([]auditScenario, error) { +func auditTestScenarios(mode auditTestMode, config gowdk.Config, manifest securitymanifest.SecurityManifest, specs []gwdkir.AuditSpec) ([]auditScenario, error) { var scenarios []auditScenario routes := append([]securitymanifest.RouteEntry(nil), manifest.Routes...) sort.SliceStable(routes, func(i, j int) bool { @@ -204,7 +204,7 @@ func auditTestScenarios(config gowdk.Config, manifest securitymanifest.SecurityM }) } - testScenarios, err := auditDeclaredTestScenarios(specs) + testScenarios, err := auditDeclaredTestScenarios(mode, specs) if err != nil { return nil, err } @@ -243,7 +243,7 @@ func auditSecurityHeaders(config gowdk.Config) []auditHeaderExpectation { return headers } -func auditDeclaredTestScenarios(specs []gwdkir.AuditSpec) ([]auditScenario, error) { +func auditDeclaredTestScenarios(mode auditTestMode, specs []gwdkir.AuditSpec) ([]auditScenario, error) { var scenarios []auditScenario for _, spec := range specs { for _, test := range spec.Tests { @@ -259,6 +259,15 @@ func auditDeclaredTestScenarios(specs []gwdkir.AuditSpec) ([]auditScenario, erro if err != nil { return nil, fmt.Errorf("%s:%d: invalid audit test status %q", spec.Source, test.Span.Start.Line+lineIndex+1, statusMatch[4]) } + // The standalone harness installs no auth provider and only + // models static serving, default-deny, and headers, so it + // cannot enforce role/permission guards. An actor expectation + // there would pass or fail for the wrong reason, so refuse it + // and steer the author to the generated-app audit test, which + // runs against the real guard pipeline. + if mode == auditTestStandalone && statusMatch[3] != "" { + return nil, fmt.Errorf("%s:%d: audit test actor %q requires the generated-app audit test (%s, emitted by gowdk build); gowdk audit --run cannot enforce role or permission guards", spec.Source, test.Span.Start.Line+lineIndex+1, statusMatch[3], auditTestFileName) + } name := test.Name + " " + strings.ToUpper(statusMatch[1]) + " " + statusMatch[2] if statusMatch[3] != "" { name += " as " + statusMatch[3] @@ -291,6 +300,8 @@ func auditDeclaredTestScenarios(specs []gwdkir.AuditSpec) ([]auditScenario, erro } func writeGeneratedAuditAuthProvider(builder *strings.Builder) { + builder.WriteString("\tpreviousAuditAuthProvider := authProvider\n") + builder.WriteString("\tt.Cleanup(func() { authProvider = previousAuditAuthProvider })\n") builder.WriteString("\tRegisterAuthProvider(gowdkauth.ProviderFunc(func(request *http.Request) (*gowdkauth.Principal, error) {\n") builder.WriteString("\t\tactor := strings.TrimSpace(request.Header.Get(\"X-GOWDK-Audit-Actor\"))\n") builder.WriteString("\t\tswitch {\n") diff --git a/internal/auditspec/engine.go b/internal/auditspec/engine.go index 4b1a7d6c..8666dd05 100644 --- a/internal/auditspec/engine.go +++ b/internal/auditspec/engine.go @@ -57,7 +57,25 @@ func Evaluate(manifest securitymanifest.SecurityManifest, policies []Policy) []F } findings = append(findings, unmatchedSelectorFindings(resolved, matchedAnything)...) - return findings + return dedupeFindings(findings) +} + +// dedupeFindings drops findings that are identical except for the policy that +// raised them, so a policy that extends a baseline policy does not re-report the +// same sink, route, or endpoint twice. The first occurrence wins, which keeps +// the baseline attribution because ComposeBaseline lists baseline policies first. +func dedupeFindings(findings []Finding) []Finding { + seen := make(map[string]bool, len(findings)) + out := findings[:0] + for _, finding := range findings { + key := finding.Code + "\x00" + finding.Target + "\x00" + finding.Source + "\x00" + finding.Message + if seen[key] { + continue + } + seen[key] = true + out = append(out, finding) + } + return out } // resolve expands extends so each policy carries its full rule set, and reports @@ -212,9 +230,13 @@ func evalEndpoint(endpoint securitymanifest.EndpointEntry, policy Policy) []Find switch rule.Kind { case RuleRequireCSRF: if !endpoint.CSRF { - findings = append(findings, finding(rule, policy, endpointTarget(endpoint), endpoint.Source, + csrfRule := rule + if csrfRule.Code == "" { + csrfRule.Code = csrfCodeForKind(endpoint.Kind) + } + findings = append(findings, finding(csrfRule, policy, endpointTarget(endpoint), endpoint.Source, fmt.Sprintf("%s endpoint %s does not enforce CSRF", endpoint.Kind, endpoint.ID), - "Enable Build.CSRF.Enabled, or waive the rule with a documented reason.")) + "Enable Build.CSRF.Enabled, or override the matching baseline policy in a *.audit.gwdk file.")) } case RuleRequireAnyGuard: if endpoint.DefaultDeny { @@ -342,6 +364,16 @@ func finding(rule Rule, policy Policy, target, source, message, remediation stri } } +// csrfCodeForKind resolves the diagnostic code for a CSRF requirement when a +// declared rule did not pin one, so a command endpoint reports the command code +// rather than the action code. +func csrfCodeForKind(kind string) string { + if kind == "command" { + return "audit_command_missing_csrf" + } + return "audit_action_missing_csrf" +} + func endpointTarget(endpoint securitymanifest.EndpointEntry) string { return endpoint.Kind + ":" + endpoint.ID } diff --git a/internal/auditspec/engine_test.go b/internal/auditspec/engine_test.go index d70e4c28..81bbc8cd 100644 --- a/internal/auditspec/engine_test.go +++ b/internal/auditspec/engine_test.go @@ -104,6 +104,52 @@ func TestPolicyRequireHeaderUsesConfiguredHeaders(t *testing.T) { } } +func TestDeclaredRequireCSRFResolvesCodeByEndpointKind(t *testing.T) { + manifest := securitymanifest.SecurityManifest{ + Endpoints: []securitymanifest.EndpointEntry{ + {ID: "Submit", Kind: "action", Method: "POST", Path: "/signup", Guards: []string{"auth.required"}, CSRF: false}, + {ID: "patients.CreatePatient", Kind: "command", Method: "POST", Path: "/patients", Guards: []string{"auth.required"}, CSRF: false}, + }, + } + // A declared require csrf rule leaves Code empty (as the parser now does), so + // the engine resolves the kind-appropriate code for each matched endpoint. + policies := []Policy{{ + Name: "csrf_everywhere", + Source: "security.audit.gwdk:1", + Selectors: []Selector{{Raw: "act:*", Kind: SelectorEndpoint}, {Raw: "command:*", Kind: SelectorEndpoint}}, + Rules: []Rule{{Kind: RuleRequireCSRF}}, + }} + got := codes(Evaluate(manifest, policies)) + if got["audit_action_missing_csrf"] != 1 { + t.Fatalf("expected one action CSRF finding, got %#v", got) + } + if got["audit_command_missing_csrf"] != 1 { + t.Fatalf("expected one command CSRF finding, got %#v", got) + } +} + +func TestEvaluateDeduplicatesFindingsAcrossExtendingPolicies(t *testing.T) { + manifest := securitymanifest.SecurityManifest{ + Frontend: securitymanifest.FrontendSurface{ + RawHTMLSinks: []securitymanifest.RawHTMLSink{ + {OwnerKind: "page", OwnerID: "home", Field: "{TrustedHTML}", Source: "home.page.gwdk:12"}, + }, + }, + } + // browser_hardening extends baseline.frontend (which already denies raw HTML) + // and denies raw HTML again, so the same sink is evaluated three times. + declared := []Policy{{ + Name: "browser_hardening", + Source: "security.audit.gwdk:3", + Extends: []string{"baseline.frontend"}, + Selectors: []Selector{{Raw: "frontend", Kind: SelectorFrontend}}, + Rules: []Rule{{Kind: RuleDenyRawHTMLSinks, Code: "audit_raw_html_sink"}}, + }} + if got := codes(Evaluate(manifest, ComposeBaseline(declared)))["audit_raw_html_sink"]; got != 1 { + t.Fatalf("expected one deduped raw HTML finding, got %d", got) + } +} + func TestComposeBaselineLetsDeclaredPolicyOverrideBuiltin(t *testing.T) { policies := ComposeBaseline([]Policy{{ Name: "baseline.frontend", diff --git a/internal/diagnostics/explain.go b/internal/diagnostics/explain.go index 1c293f66..567c042c 100644 --- a/internal/diagnostics/explain.go +++ b/internal/diagnostics/explain.go @@ -217,7 +217,7 @@ guard public Details: "gowdk audit derives an action endpoint that decodes a request body without CSRF enforcement. The built-in security baseline (and any require csrf policy) treats this as an error because action POSTs are cross-site-forgeable.", NextSteps: []string{ "Set Build.CSRF.Enabled and a runtime CSRF secret so generated actions validate tokens before decoding.", - "Use an audit policy waiver with a documented reason if the endpoint is intentionally exempt.", + "Override the built-in baseline.actions policy in a *.audit.gwdk file if the endpoint is intentionally exempt.", }, }, "audit_api_public_by_omission": { @@ -235,7 +235,7 @@ guard public }, }, "audit_client_route_unguarded": { - Details: "A client or SPA route is not covered by the generated default-deny registry, so static hosting could serve it without the 403 gate. The static-export caveat in docs/language/guards.md applies.", + Details: "A client or SPA route declares no guard, so it is protected only by the generated runtime default-deny gate (HTTP 403). Under pure static hosting that gate is absent and the route's HTML could be served. The static-export caveat in docs/language/guards.md applies.", NextSteps: []string{ "State the route's access with guard public or a protective guard so it joins the deny registry.", "Serve the route through the generated Go server, which enforces the deny registry.", @@ -245,7 +245,7 @@ guard public Details: "gowdk audit derives a generated command endpoint that accepts a state-changing web request without CSRF enforcement. The built-in security baseline treats this as an error because command POSTs are cross-site-forgeable in the same way as action POSTs.", NextSteps: []string{ "Set Build.CSRF.Enabled and a runtime CSRF secret so generated command endpoints validate tokens before decoding.", - "Use an audit policy waiver with a documented reason if the endpoint is intentionally exempt.", + "Override the built-in baseline.contract_commands policy in a *.audit.gwdk file if the endpoint is intentionally exempt.", }, }, "audit_guardless_endpoint_page": { @@ -280,7 +280,7 @@ guard public Details: "A target route or endpoint is public (guard public or no protective guard) but a policy deny public rule forbids public access for its selector.", NextSteps: []string{ "Add a protective guard to the target so it is no longer public.", - "Narrow the policy selector or add a waiver if the target is intentionally public.", + "Narrow the policy selector if the target is intentionally public.", }, }, "audit_raw_html_sink": { diff --git a/internal/diagnostics/registry.go b/internal/diagnostics/registry.go index f8a4055a..e582fb98 100644 --- a/internal/diagnostics/registry.go +++ b/internal/diagnostics/registry.go @@ -63,7 +63,7 @@ var Registry = []Code{ {Code: "audit_action_missing_csrf", Area: "audit", Stability: StabilityExperimental, Severity: SeverityError, Summary: "action endpoint does not enforce CSRF as required by the security baseline or policy"}, {Code: "audit_api_public_by_omission", Area: "audit", Stability: StabilityExperimental, Severity: SeverityError, Summary: "API endpoint inherits no protective guard and policy forbids public-by-omission APIs"}, {Code: "audit_bundle_secret", Area: "audit", Stability: StabilityExperimental, Severity: SeverityError, Summary: "embedded build output or build-time data carries a secret-shaped value"}, - {Code: "audit_client_route_unguarded", Area: "audit", Stability: StabilityExperimental, Severity: SeverityError, Summary: "a client or SPA route is not covered by the generated default-deny registry"}, + {Code: "audit_client_route_unguarded", Area: "audit", Stability: StabilityExperimental, Severity: SeverityWarning, Summary: "a client or SPA route declares no guard and is protected only by the generated runtime default-deny gate, which is absent under static export"}, {Code: "audit_command_missing_csrf", Area: "audit", Stability: StabilityExperimental, Severity: SeverityError, Summary: "command endpoint does not enforce CSRF as required by the security baseline or policy"}, {Code: "audit_guardless_endpoint_page", Area: "audit", Stability: StabilityExperimental, Severity: SeverityError, Summary: "a page exposing backend endpoints declares no guard"}, {Code: "audit_headers_missing", Area: "audit", Stability: StabilityExperimental, Severity: SeverityWarning, Summary: "generated app does not declare a security response header required by policy"}, diff --git a/internal/parser/audit.go b/internal/parser/audit.go index eab2ae60..ea108781 100644 --- a/internal/parser/audit.go +++ b/internal/parser/audit.go @@ -249,7 +249,9 @@ func parseAuditRequireRule(tokens []syntax.Token, lineNumber int, rawLine string switch tokens[1].Lexeme { case "csrf": rule.Kind = "require_csrf" - rule.Code = "audit_action_missing_csrf" + // Leave Code empty so the engine resolves a kind-appropriate code per + // matched endpoint (action vs command). An explicit `as ` still + // overrides this in finishAuditRule. return finishAuditRule(rule, tokens[2:], lineNumber) case "guard": if len(tokens) < 3 {