Skip to content
Merged
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
1 change: 1 addition & 0 deletions .claude/knowledge/architecture-boundaries.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ Key architectural decisions, service boundaries, data flow, integration points,
- The taint engine emits a single rule ID per language, chosen by sink: `goTaintRuleID`/`pyTaintRuleID` return `security.ssrf.{go,python}` when the sink is an outbound-HTTP callee (`goHTTPSinkArgIndex`/`pyHTTPSinkArgIndex`), else the generic `security.taint.{go,python}`. To add an SSRF sink, add it to the arg-index map; the rule routing and OWASP A10 mapping follow automatically.
- **Section dispatch is data-driven, not a hardcoded ladder.** `runner/checks/Build` iterates `sectionRegistry` (`runner/checks/registry.go`) — a `[]sectionDef{id, name, enabled(sc), run(ctx, sc, checkEnv)}`. To add a check section, append a `sectionDef`; do NOT reintroduce an `if cfg.X {...}` ladder. Every section runs through `safeRun` (in `checks.go`), which recovers panics into a `StatusWarn` SectionResult with a `checks.section.panic` finding — so a panic in one check degrades that section to a warning instead of aborting the whole scan. Preserve registry ordering; it determines output section order.
- **Git subprocess hardening contract (threat model: untrusted PR).** `base_ref` from MCP/CLI is validated by `runnersupport.ValidateBaseRef` (rejects leading `-` to block git option injection; restricts charset; preserves the `stdin` sentinel) BEFORE reaching git, and `--end-of-options` is passed to git diff/show/worktree. All git/govulncheck subprocesses use `exec.CommandContext` with a contained timeout and read output through `io.LimitReader` (`maxGitOutputBytes`) to prevent hangs/OOM. The `//nolint:contextcheck` markers in the git layer (`runner/support`, `ai/fix`, `ai/semantic`, `checks/quality/quality.go`, `runner/runner.go`) are DELIBERATE — the contained timeout is the chosen design; threading caller `ctx` end-to-end is a tracked follow-up. Don't "fix" them by removing the nolint without actually threading ctx. `runner/support/MatchPattern` (glob→regex) uses `regexp.Compile` + a `sync.Map` cache and returns false (never panics) on a malformed config glob.
- **The usage menu makes one outbound call (opt-out).** Running `codeguard` with no args or `help` renders a "What's New" banner (`internal/whatsnew` + `internal/cli/whatsnew.go`) that queries the GitHub releases API for the latest version. It is disabled when `CODEGUARD_NO_UPDATE_CHECK` is truthy OR `$CI` is non-empty (so CI/automated runs make no surprise call), caches the result for 24h under `os.UserCacheDir()/codeguard/update-check.json`, uses the SSRF-guarded `ai/safehttp.Client` with a 1.5s timeout, and fails silent (falls back to stale cache, then to version-only). Changelog bullets come from `CHANGELOG.md` embedded via a **module-root package** (`/changelog.go`, `package codeguard`, imported as `root "github.com/devr-tools/codeguard"`) — `go:embed` cannot reference `../CHANGELOG.md`, so the embed directive must live at the repo root, not in `internal/`.
- **Lint is enforced in CI.** `make lint` is `go vet`; `make lint-strict` / the CI `golangci-lint-action` step (now blocking — no `continue-on-error`) runs the 16-linter `.golangci.yml`. `golangci-lint run` must report 0 before pushing. The config excludes revive's documentation/naming style rules (`exported`, `package-comments`, `unused-parameter`) and excludes `tests/` from gosec/errcheck/prealloc/bodyclose (intentional fixtures). Cache/identity keys use sha256 (not sha1). Documenting the public `pkg/codeguard` API with doc comments is a known follow-up (revive `exported` is currently excluded rather than satisfied).
13 changes: 12 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,19 @@ CI_CONFIG ?= .codeguard/codeguard.yaml
BASE_REF ?= main
CODEGUARD_BIN ?= ./dist/codeguard
GOFILES := $(shell find cmd internal pkg tests -type f -name '*.go' 2>/dev/null)
# MENU_VERSION defaults to the current release-please version so `make menu`
# previews the banner with the real version (like a release build) instead of
# the source default; override with MENU_VERSION=x.y.z.
MANIFEST_VERSION := $(shell grep -oE '[0-9]+\.[0-9]+\.[0-9]+' .release-please-manifest.json 2>/dev/null | head -1)
MENU_VERSION ?= $(if $(MANIFEST_VERSION),$(MANIFEST_VERSION),$(VERSION))
MENU_LDFLAGS := -X github.com/devr-tools/codeguard/internal/version.Number=v$(MENU_VERSION)

export GOCACHE
export GOMODCACHE

.DEFAULT_GOAL := help

.PHONY: help fmt fmt-check lint lint-strict test codeguard-ci check ci build release release-snapshot release-check deploy commit table table-diff table-interactive clean
.PHONY: help fmt fmt-check lint lint-strict test codeguard-ci check ci build release release-snapshot release-check deploy commit menu table table-diff table-interactive clean

help:
@printf "\ncodeguard make targets\n\n"
Expand All @@ -42,6 +48,7 @@ help:
@printf " make release-snapshot Build local snapshot release artifacts\n"
@printf " make deploy Alias for make release\n"
@printf " make commit Create an interactive conventional commit\n"
@printf " make menu Preview the What's New banner and usage menu\n"
@printf " make table Run a full scan and print the terminal table\n"
@printf " make table-diff Run a diff scan and print the terminal table\n"
@printf " make table-interactive Launch interactive terminal scanning\n"
Expand Down Expand Up @@ -93,6 +100,10 @@ deploy: release
commit:
./scripts/commit.sh

menu:
@printf "# codeguard menu preview (v%s); set CODEGUARD_NO_UPDATE_CHECK=1 to skip the update check\n\n" "$(MENU_VERSION)"
$(GO) run -ldflags "$(MENU_LDFLAGS)" ./cmd/codeguard

table:
$(GO) run ./cmd/codeguard scan -config $(CONFIG)

Expand Down
13 changes: 13 additions & 0 deletions changelog.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Package codeguard exposes module-level assets that must live at the
// repository root. Currently it embeds CHANGELOG.md so the CLI can surface
// recent release highlights in its "What's New" banner without shipping a
// duplicate copy of the changelog.
package codeguard

import _ "embed"

// Changelog is the full contents of CHANGELOG.md, embedded at build time.
// It is generated by release-please in conventional-changelog format.
//
//go:embed CHANGELOG.md
var Changelog string
25 changes: 3 additions & 22 deletions internal/cli/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import (
service "github.com/devr-tools/codeguard/pkg/codeguard"
)

// promptString and loadConfigWithProfile live here; the command menu is
// rendered by writeMenu in menu.go.

func promptString(reader *bufio.Reader, stdout io.Writer, label string, fallback string) (string, error) {
if _, err := fmt.Fprintf(stdout, "%s [%s]: ", label, fallback); err != nil {
return "", err
Expand All @@ -24,28 +27,6 @@ func promptString(reader *bufio.Reader, stdout io.Writer, label string, fallback
return line, nil
}

func writeUsage(w io.Writer) {
_, _ = io.WriteString(w, `codeguard checks repositories for code quality, design, security, CI, prompt safety, and custom policy rules.

Usage:
codeguard init [-output codeguard.yaml] [-interactive] [-profile startup|strict|enterprise|ai-safe]
codeguard validate [-config codeguard.yaml] [-profile startup|strict|enterprise|ai-safe]
codeguard validate-patch [-config codeguard.yaml] [-format text|json|sarif|github] [-profile startup|strict|enterprise|ai-safe] [-ai] < patch.diff
codeguard scan [-config codeguard.yaml] [-mode full|diff] [-base-ref main] [-format text|json|sarif|github] [-interactive] [-profile startup|strict|enterprise|ai-safe] [-ai] [-allow-config-commands] [-allow-config-ai-endpoints]
codeguard scan-history [-config codeguard.yaml] [-path .] [-max-commits N] [-all] [-format text|json] # scan git history for committed secrets
codeguard fix [-config codeguard.yaml] [-mode full|diff] [-base-ref main] [-profile startup|strict|enterprise|ai-safe] [-rule rule.id] [-path rel/path] [-line N] -ai
codeguard baseline [-config codeguard.yaml] [-output codeguard-baseline.json] [-mode full|diff] [-base-ref main] [-profile startup|strict|enterprise|ai-safe]
codeguard report -slop-history [-config codeguard.yaml] [-limit N] [-profile startup|strict|enterprise|ai-safe]
codeguard rules [-config codeguard.yaml]
codeguard explain [-config codeguard.yaml] [-format text|agent] <rule-id>
codeguard owasp [-config codeguard.yaml] [-format text|json] [-profile startup|strict|enterprise|ai-safe]
codeguard serve --mcp [-config codeguard.yaml] [-profile startup|strict|enterprise|ai-safe]
codeguard doctor [-config codeguard.yaml] [-profile startup|strict|enterprise|ai-safe]
codeguard profiles
codeguard version
`)
}

func loadConfigWithProfile(path string, profile string) (service.Config, error) {
cfg, err := service.LoadConfigFile(path)
if err != nil {
Expand Down
116 changes: 116 additions & 0 deletions internal/cli/menu.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package cli

import (
"fmt"
"io"
"strings"

"github.com/devr-tools/codeguard/internal/whatsnew"
)

// menuItem is a single command row in the menu.
type menuItem struct {
name string
desc string
}

// menuGroup is a titled cluster of related commands.
type menuGroup struct {
title string
items []menuItem
}

// menuGroups defines the command menu layout, grouped by task.
var menuGroups = []menuGroup{
{
title: "Get started",
items: []menuItem{
{"init", "Create a codeguard config file"},
{"validate", "Check that a config file is valid"},
{"profiles", "List built-in policy profiles"},
},
},
{
title: "Scan & baseline",
items: []menuItem{
{"scan", "Scan the working tree or a diff for violations"},
{"scan-history", "Scan git history for committed secrets"},
{"validate-patch", "Scan a unified diff piped on stdin"},
{"baseline", "Record current findings as an accepted baseline"},
},
},
{
title: "Fix & report",
items: []menuItem{
{"fix", "AI-assisted fix for a specific finding"},
{"report", "Print the slop / AI-risk history report"},
},
},
{
title: "Rules & policy",
items: []menuItem{
{"rules", "List all rules and their metadata"},
{"explain", "Explain a rule by id"},
{"owasp", "Show OWASP category coverage"},
},
},
{
title: "Serve & diagnose",
items: []menuItem{
{"serve", "Run the Model Context Protocol (MCP) server"},
{"doctor", "Diagnose config and environment"},
{"version", "Print the codeguard version"},
},
},
}

// writeMenu renders the command menu: a short tagline, task-grouped commands
// with aligned descriptions, and a footer pointing at per-command help. Command
// names are styled in devr blue when w is a color-capable terminal.
func writeMenu(w io.Writer) {
color := whatsnew.ColorForWriter(w)

_, _ = fmt.Fprintln(w, "codeguard — code quality, design, security, CI, and prompt-safety checks for your repository.")
_, _ = fmt.Fprintf(w, "\nUsage: %s %s %s\n\n",
whatsnew.BlueBold("codeguard", color),
whatsnew.Blue("<command>", color),
whatsnew.Faint("[flags]", color))

width := menuNameWidth()
for _, group := range menuGroups {
_, _ = fmt.Fprintf(w, " %s\n", whatsnew.Faint(strings.ToUpper(group.title), color))
for _, item := range group.items {
_, _ = fmt.Fprintf(w, " %s %s\n", whatsnew.Blue(pad(item.name, width), color), item.desc)
}
_, _ = fmt.Fprintln(w)
}

_, _ = fmt.Fprintf(w, " %s\n", whatsnew.Faint("Common flags", color))
_, _ = fmt.Fprintf(w, " %s %s\n", whatsnew.Blue(pad("-config", width), color), "Path to a config file or directory")
_, _ = fmt.Fprintf(w, " %s %s\n", whatsnew.Blue(pad("-profile", width), color), "startup | strict | enterprise | ai-safe")
_, _ = fmt.Fprintf(w, " %s %s\n\n", whatsnew.Blue(pad("-format", width), color), "text | json | sarif | github")

_, _ = fmt.Fprintf(w, "Run %s to see all flags for a command.\n",
whatsnew.Blue("codeguard <command> -h", color))
}

// menuNameWidth returns the column width for command/flag names: the longest
// name plus padding, so descriptions align.
func menuNameWidth() int {
longest := len("-profile") // keep the Common flags column aligned too.
for _, group := range menuGroups {
for _, item := range group.items {
if len(item.name) > longest {
longest = len(item.name)
}
}
}
return longest
}

func pad(s string, width int) string {
if len(s) >= width {
return s
}
return s + strings.Repeat(" ", width-len(s))
}
8 changes: 5 additions & 3 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,15 @@ var commandCatalog = map[string]commandRunner{

func Run(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int {
if len(args) == 0 {
writeUsage(stdout)
writeWhatsNew(stdout)
writeMenu(stdout)
return 0
}

command := args[0]
if isHelpCommand(command) {
writeUsage(stdout)
writeWhatsNew(stdout)
writeMenu(stdout)
return 0
}
if command == "version" {
Expand All @@ -46,7 +48,7 @@ func Run(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int
}

_, _ = fmt.Fprintf(stderr, "unknown command %q\n\n", command)
writeUsage(stderr)
writeMenu(stderr)
return 1
}

Expand Down
20 changes: 20 additions & 0 deletions internal/cli/whatsnew.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package cli

import (
"context"
"io"

root "github.com/devr-tools/codeguard"
"github.com/devr-tools/codeguard/internal/version"
"github.com/devr-tools/codeguard/internal/whatsnew"
)

// writeWhatsNew renders the "What's New" banner above the usage menu: the
// running version, an upstream update notice (from a cached, opt-out release
// check), and the latest changelog highlights. Any failure degrades to showing
// less, never blocking the menu.
func writeWhatsNew(stdout io.Writer) {
rel, _ := whatsnew.LatestFromChangelog(root.Changelog)
latest, _ := whatsnew.DefaultChecker().LatestVersion(context.Background())
whatsnew.Render(stdout, version.Number, latest, rel, whatsnew.ColorForWriter(stdout))
}
7 changes: 6 additions & 1 deletion internal/version/version.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
package version

const Number = "0.1.0"
// Number is the codeguard version. It must be a var (not a const) so the
// release build can override it via the linker: GoReleaser injects the git tag
// with `-X github.com/devr-tools/codeguard/internal/version.Number=v{{.Version}}`
// (see .goreleaser.yaml). The linker's -X flag only sets string vars, so a
// const would silently leave released binaries reporting this default.
var Number = "0.1.0"
56 changes: 56 additions & 0 deletions internal/whatsnew/color.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package whatsnew

import (
"io"
"os"
"strings"
)

// Brand ANSI SGR codes. devrBlue matches codeguard's brand blue (RGB
// 37,169,255) used by the report banner (internal/codeguard/report).
const (
devrBlue = "38;2;37;169;255"
devrBlueBold = "1;38;2;37;169;255"
dim = "2"
reset = "\x1b[0m"
)

// ColorForWriter reports whether ANSI color should be emitted to w: only when
// w is a terminal (char device), NO_COLOR is unset, and TERM is not "dumb".
// Writing to a pipe, file, or bytes.Buffer yields false, so redirected output
// and tests stay plain text.
func ColorForWriter(w io.Writer) bool {
if strings.TrimSpace(os.Getenv("NO_COLOR")) != "" {
return false
}
if strings.EqualFold(strings.TrimSpace(os.Getenv("TERM")), "dumb") {
return false
}
f, ok := w.(*os.File)
if !ok {
return false
}
info, err := f.Stat()
if err != nil {
return false
}
return info.Mode()&os.ModeCharDevice != 0
}

// paint wraps s in the given SGR code when color is enabled, otherwise returns
// s unchanged.
func paint(color bool, code, s string) string {
if !color || code == "" {
return s
}
return "\x1b[" + code + "m" + s + reset
}

// Blue renders s in codeguard's devr blue when color is enabled.
func Blue(s string, color bool) string { return paint(color, devrBlue, s) }

// BlueBold renders s in bold devr blue when color is enabled.
func BlueBold(s string, color bool) string { return paint(color, devrBlueBold, s) }

// Faint renders s dimmed when color is enabled.
func Faint(s string, color bool) string { return paint(color, dim, s) }
Loading
Loading