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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <code>` | Explain a diagnostic code and its next steps |
| `gowdk doctor` | Check local environment and project health |
| `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 |
Expand Down Expand Up @@ -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, 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) |

Expand Down
264 changes: 264 additions & 0 deletions cmd/gowdk/audit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
package main

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 <file>] [--module <name>] [--ssr] [--json] [--emit-tests[=<file>]] [--run] [files...]"

// auditReport is the gowdk audit result: the derived security posture plus the
// findings from evaluating the built-in baseline and 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 auditCommandOptions struct {
EmitTests bool
RunTests bool
TestPath string
}

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 {
auditOptions, projectArgs, err := parseAuditCommandOptions(args)
if err != nil {
return err
}
options, paths, err := loadCommandInputs(projectArgs, "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)
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)

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 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 {
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)
}
}
Loading