diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 1efc1d4..c1ca6ff 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -101,7 +101,7 @@ jobs: - name: Install gosec if: steps.changes.outputs.go == 'true' || steps.changes.outputs.workflow_or_policy == 'true' - run: go install github.com/securego/gosec/v2/cmd/gosec@v2.22.1 + run: go install github.com/securego/gosec/v2/cmd/gosec@v2.23.0 - name: Run gosec if: steps.changes.outputs.go == 'true' || steps.changes.outputs.workflow_or_policy == 'true' @@ -110,7 +110,7 @@ jobs: - name: Install golangci-lint if: steps.changes.outputs.go == 'true' || steps.changes.outputs.workflow_or_policy == 'true' - run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.63.4 + run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.0.1 - name: Run golangci-lint if: steps.changes.outputs.go == 'true' || steps.changes.outputs.workflow_or_policy == 'true' diff --git a/README.md b/README.md index 8f7acea..72f4cd3 100644 --- a/README.md +++ b/README.md @@ -14,14 +14,19 @@ Wrkr runs standalone and interoperates through shared `Clyra-AI/proof` contracts ## Status -Epics 1-6 are implemented. - -- Epic 1: source acquisition contracts (`init`, `scan`, source manifests, incremental diff state) -- Epic 2: deterministic detector engine (Claude/Cursor/Codex/Copilot, MCP, skills, CI/headless autonomy, dependencies, secrets, compiled actions) and YAML-backed policy evaluation (`WRKR-001`..`WRKR-015`) -- Epic 3: deterministic inventory aggregation + repo exposure summaries, identity lifecycle manifest/chain updates, ranked risk reporting, posture profiles, and posture score outputs -- Epic 4: signed proof record emission (`scan_finding`, `risk_assessment`, `approval`, lifecycle transitions), proof chain verification, and compliance evidence bundle generation -- Epic 5: CLI contract hardening (`--json`, `--quiet`, `--explain`), report PDF output, manifest generation, and posture regression baseline/drift checks -- Epic 6: deterministic remediation planning (`fix`), split auth-profile PR safeguards, and `wrkr-action` scheduled/PR runtime contracts +Wrkr is in v1 contract-hardening phase with deterministic end-to-end workflows implemented for: + +- discovery and scan target modes (`init`, `scan`, diff state) +- deterministic detection, policy/profile evaluation, and ranked risk output +- identity lifecycle, manifest generation, and proof chain verification +- compliance evidence export, reporting artifacts, posture scoring, and regression baselines +- deterministic remediation planning (`fix`) and auth-profile safeguards + +Coverage and contract health are enforced by: + +- acceptance flow tests in `internal/acceptance/v1_acceptance_test.go` +- scenario coverage mapping in `internal/scenarios/coverage_map.json` +- CI contract lanes (`make test-contracts`, `make prepush-full`, CodeQL) ## Quick Start diff --git a/SECURITY.md b/SECURITY.md index a95cc0e..c3e2f64 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,3 +1,46 @@ # Security Policy -Report security issues privately to maintainers. Do not open public issues for active vulnerabilities. +## Private Reporting + +Do not open public issues for suspected vulnerabilities. + +Report privately using GitHub Security Advisories: + +- + +If GitHub private advisories are unavailable, open a maintainers-only channel and include "SECURITY" in the title. + +## What To Include + +Please include: + +- affected component/file/command and version/commit +- impact summary (confidentiality, integrity, availability) +- reproduction steps with deterministic inputs +- proof-of-concept or logs (scrub secrets) +- suggested mitigations/workarounds if known + +## Response Expectations + +- acknowledgment: within 3 business days +- triage/update: within 7 business days after acknowledgment +- remediation target: +- critical/high severity: target fix or mitigation within 30 days +- medium/low severity: target fix in a scheduled release cycle + +Timelines may shift for complex supply-chain or coordinated multi-project issues; maintainers will communicate status updates in the advisory thread. + +## Supported Fix Targets + +Security fixes are prioritized for: + +- `main` +- latest supported release line/tag maintained by the project + +Older, unsupported lines may not receive backports. + +## Disclosure Coordination + +- keep details private until maintainers confirm a fix or mitigation is available +- coordinate publication timing with maintainers to protect downstream users +- when disclosed, include affected versions, fixed versions, and upgrade guidance diff --git a/core/cli/evidence.go b/core/cli/evidence.go index ae599ea..cdcdf75 100644 --- a/core/cli/evidence.go +++ b/core/cli/evidence.go @@ -26,8 +26,8 @@ func runEvidence(args []string, stdout io.Writer, stderr io.Writer) int { outputDir := fs.String("output", "wrkr-evidence", "evidence output directory") statePathFlag := fs.String("state", "", "state file path override") - if err := fs.Parse(args); err != nil { - return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput) + if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled { + return code } if fs.NArg() != 0 { return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", "evidence does not accept positional arguments", exitInvalidInput) diff --git a/core/cli/export.go b/core/cli/export.go index cca1fcb..e091d75 100644 --- a/core/cli/export.go +++ b/core/cli/export.go @@ -25,8 +25,8 @@ func runExport(args []string, stdout io.Writer, stderr io.Writer) int { format := fs.String("format", "inventory", "export format") statePathFlag := fs.String("state", "", "state file path override") - if err := fs.Parse(args); err != nil { - return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput) + if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled { + return code } if *format != "inventory" { return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", "unsupported export format", exitInvalidInput) diff --git a/core/cli/fix.go b/core/cli/fix.go index 40fc53b..f12b875 100644 --- a/core/cli/fix.go +++ b/core/cli/fix.go @@ -47,8 +47,8 @@ func runFix(args []string, stdout io.Writer, stderr io.Writer) int { githubAPI := fs.String("github-api", strings.TrimSpace(os.Getenv("WRKR_GITHUB_API_BASE")), "github api base url") fixToken := fs.String("fix-token", "", "fix profile token override") - if err := fs.Parse(args); err != nil { - return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput) + if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled { + return code } if fs.NArg() != 0 { return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", "fix does not accept positional arguments", exitInvalidInput) diff --git a/core/cli/identity.go b/core/cli/identity.go index 3b2fb4e..9818ff5 100644 --- a/core/cli/identity.go +++ b/core/cli/identity.go @@ -20,6 +20,10 @@ func runIdentity(args []string, stdout io.Writer, stderr io.Writer) int { if len(args) == 0 { return emitError(stderr, wantsJSONOutput(args), "invalid_input", "identity subcommand is required", exitInvalidInput) } + if isHelpFlag(args[0]) { + _, _ = fmt.Fprintln(stderr, "Usage of wrkr identity: identity [flags]") + return exitSuccess + } subcommand := args[0] subArgs := args[1:] switch subcommand { @@ -50,8 +54,8 @@ func runIdentityList(args []string, stdout io.Writer, stderr io.Writer) int { } jsonOut := fs.Bool("json", false, "emit machine-readable output") statePathFlag := fs.String("state", "", "state file path override") - if err := fs.Parse(args); err != nil { - return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput) + if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled { + return code } loaded, err := manifest.Load(manifest.ResolvePath(state.ResolvePath(*statePathFlag))) if err != nil { @@ -83,8 +87,8 @@ func runIdentityShow(args []string, stdout io.Writer, stderr io.Writer) int { } jsonOut := fs.Bool("json", false, "emit machine-readable output") statePathFlag := fs.String("state", "", "state file path override") - if err := fs.Parse(args); err != nil { - return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput) + if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled { + return code } agentID := strings.TrimSpace(preID) if agentID == "" { @@ -146,8 +150,8 @@ func runIdentityApprove(args []string, stdout io.Writer, stderr io.Writer) int { scope := fs.String("scope", "", "approval scope") expires := fs.String("expires", "90d", "approval validity duration") statePathFlag := fs.String("state", "", "state file path override") - if err := fs.Parse(args); err != nil { - return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput) + if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled { + return code } agentID := strings.TrimSpace(preID) if agentID == "" { @@ -185,8 +189,8 @@ func runIdentityTransition(args []string, stdout io.Writer, stderr io.Writer, st jsonOut := fs.Bool("json", false, "emit machine-readable output") reason := fs.String("reason", "", "transition reason") statePathFlag := fs.String("state", "", "state file path override") - if err := fs.Parse(args); err != nil { - return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput) + if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled { + return code } agentID := strings.TrimSpace(preID) if agentID == "" { diff --git a/core/cli/init.go b/core/cli/init.go index 46fa506..cc376b4 100644 --- a/core/cli/init.go +++ b/core/cli/init.go @@ -31,8 +31,8 @@ func runInit(args []string, stdout io.Writer, stderr io.Writer) int { fixToken := fs.String("fix-token", "", "read-write token for fix profile") configPathFlag := fs.String("config", "", "config file path override") - if err := fs.Parse(args); err != nil { - return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput) + if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled { + return code } configPath, err := config.ResolvePath(*configPathFlag) diff --git a/core/cli/lifecycle.go b/core/cli/lifecycle.go index 2db9dc1..09d889d 100644 --- a/core/cli/lifecycle.go +++ b/core/cli/lifecycle.go @@ -29,8 +29,8 @@ func runLifecycle(args []string, stdout io.Writer, stderr io.Writer) int { reportShareProfile := fs.String("share-profile", "internal", "summary share profile [internal|public]") reportTop := fs.Int("top", 5, "number of top findings included in lifecycle summary artifact") - if err := fs.Parse(args); err != nil { - return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput) + if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled { + return code } resolvedStatePath := state.ResolvePath(*statePathFlag) diff --git a/core/cli/manifest.go b/core/cli/manifest.go index abda00a..02ad765 100644 --- a/core/cli/manifest.go +++ b/core/cli/manifest.go @@ -18,6 +18,10 @@ func runManifest(args []string, stdout io.Writer, stderr io.Writer) int { if len(args) == 0 { return emitError(stderr, wantsJSONOutput(args), "invalid_input", "manifest subcommand is required", exitInvalidInput) } + if isHelpFlag(args[0]) { + _, _ = fmt.Fprintln(stderr, "Usage of wrkr manifest: manifest [flags]") + return exitSuccess + } switch args[0] { case "generate": @@ -40,8 +44,8 @@ func runManifestGenerate(args []string, stdout io.Writer, stderr io.Writer) int statePathFlag := fs.String("state", "", "state file path override") outputPath := fs.String("output", "", "manifest output path override") - if err := fs.Parse(args); err != nil { - return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput) + if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled { + return code } if fs.NArg() != 0 { return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", "manifest generate does not accept positional arguments", exitInvalidInput) diff --git a/core/cli/regress.go b/core/cli/regress.go index 1c48f78..713ec5a 100644 --- a/core/cli/regress.go +++ b/core/cli/regress.go @@ -18,6 +18,10 @@ func runRegress(args []string, stdout io.Writer, stderr io.Writer) int { if len(args) == 0 { return emitError(stderr, wantsJSONOutput(args), "invalid_input", "regress subcommand is required", exitInvalidInput) } + if isHelpFlag(args[0]) { + _, _ = fmt.Fprintln(stderr, "Usage of wrkr regress: regress [flags]") + return exitSuccess + } switch args[0] { case "init": @@ -42,8 +46,8 @@ func runRegressInit(args []string, stdout io.Writer, stderr io.Writer) int { baselineScanPath := fs.String("baseline", "", "state snapshot path used to initialize baseline") outputPath := fs.String("output", "", "baseline artifact output path") - if err := fs.Parse(args); err != nil { - return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput) + if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled { + return code } if fs.NArg() != 0 { return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", "regress init does not accept positional arguments", exitInvalidInput) @@ -95,8 +99,8 @@ func runRegressRun(args []string, stdout io.Writer, stderr io.Writer) int { reportShareProfile := fs.String("share-profile", "internal", "summary share profile [internal|public]") reportTop := fs.Int("top", 5, "number of top findings included in regress summary artifact") - if err := fs.Parse(args); err != nil { - return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput) + if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled { + return code } if fs.NArg() != 0 { return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", "regress run does not accept positional arguments", exitInvalidInput) diff --git a/core/cli/report.go b/core/cli/report.go index 5747c90..355bb78 100644 --- a/core/cli/report.go +++ b/core/cli/report.go @@ -58,8 +58,8 @@ func runReport(args []string, stdout io.Writer, stderr io.Writer) int { baselinePath := fs.String("baseline", "", "optional regress baseline for drift summary") previousStatePath := fs.String("previous-state", "", "optional previous state for risk trend delta") - if err := fs.Parse(args); err != nil { - return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput) + if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled { + return code } if fs.NArg() != 0 { return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", "report does not accept positional arguments", exitInvalidInput) diff --git a/core/cli/root.go b/core/cli/root.go index 3b750f6..c90d6d7 100644 --- a/core/cli/root.go +++ b/core/cli/root.go @@ -77,11 +77,8 @@ func runRootFlags(args []string, stdout io.Writer, stderr io.Writer) int { quiet := fs.Bool("quiet", false, "suppress non-error output") explain := fs.Bool("explain", false, "emit human-readable rationale") - if err := fs.Parse(args); err != nil { - if errors.Is(err, flag.ErrHelp) { - return exitSuccess - } - return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput) + if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled { + return code } if fs.NArg() != 0 { return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", fmt.Sprintf("unsupported command %q", fs.Arg(0)), exitInvalidInput) @@ -111,6 +108,20 @@ func runRootFlags(args []string, stdout io.Writer, stderr io.Writer) int { return exitSuccess } +func parseFlags(fs *flag.FlagSet, args []string, stderr io.Writer, jsonOut bool) (int, bool) { + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return exitSuccess, true + } + return emitError(stderr, jsonOut, "invalid_input", err.Error(), exitInvalidInput), true + } + return 0, false +} + +func isHelpFlag(arg string) bool { + return arg == "-h" || arg == "--help" +} + func emitError(stderr io.Writer, jsonOut bool, code, message string, exitCode int) int { if jsonOut { _ = json.NewEncoder(stderr).Encode(map[string]any{ diff --git a/core/cli/root_test.go b/core/cli/root_test.go index 4f4eee8..0f8c6f3 100644 --- a/core/cli/root_test.go +++ b/core/cli/root_test.go @@ -39,6 +39,42 @@ func TestRunHelpReturnsExit0(t *testing.T) { } } +func TestRunSubcommandHelpReturnsExit0(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + }{ + {name: "init", args: []string{"init", "--help"}}, + {name: "scan", args: []string{"scan", "--help"}}, + {name: "evidence", args: []string{"evidence", "--help"}}, + {name: "report", args: []string{"report", "--help"}}, + {name: "verify", args: []string{"verify", "--help"}}, + {name: "fix", args: []string{"fix", "--help"}}, + {name: "lifecycle", args: []string{"lifecycle", "--help"}}, + {name: "regress", args: []string{"regress", "--help"}}, + {name: "regress run", args: []string{"regress", "run", "--help"}}, + {name: "manifest", args: []string{"manifest", "--help"}}, + {name: "manifest generate", args: []string{"manifest", "generate", "--help"}}, + {name: "identity", args: []string{"identity", "--help"}}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + var errOut bytes.Buffer + code := Run(tc.args, &out, &errOut) + if code != 0 { + t.Fatalf("expected exit 0 for %v, got %d (stderr=%q)", tc.args, code, errOut.String()) + } + }) + } +} + func TestRunInvalidFlagReturnsExit6(t *testing.T) { t.Parallel() diff --git a/core/cli/scan.go b/core/cli/scan.go index e259da7..95ad1c4 100644 --- a/core/cli/scan.go +++ b/core/cli/scan.go @@ -68,8 +68,8 @@ func runScan(args []string, stdout io.Writer, stderr io.Writer) int { reportShareProfile := fs.String("report-share-profile", string(reportcore.ShareProfileInternal), "scan summary share profile [internal|public]") reportTop := fs.Int("report-top", 5, "number of top findings included in scan summary artifact") - if err := fs.Parse(args); err != nil { - return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput) + if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled { + return code } targetMode, targetValue, cfg, err := resolveScanTarget(*repo, *orgTarget, *pathTarget, *configPathFlag) @@ -394,7 +394,7 @@ func evaluatePolicies(scopes []detect.Scope, findings []source.Finding, customPo func detectorScopes(manifestOut source.Manifest) []detect.Scope { scopes := make([]detect.Scope, 0, len(manifestOut.Repos)) for _, repo := range manifestOut.Repos { - info, err := os.Stat(repo.Location) + info, err := os.Stat(repo.Location) // #nosec G703 -- repo locations come from deterministic source acquisition inputs for current scan scope. if err != nil || !info.IsDir() { continue } diff --git a/core/cli/score.go b/core/cli/score.go index 5e281a3..fca69ac 100644 --- a/core/cli/score.go +++ b/core/cli/score.go @@ -26,8 +26,8 @@ func runScore(args []string, stdout io.Writer, stderr io.Writer) int { explain := fs.Bool("explain", false, "emit rationale details") statePathFlag := fs.String("state", "", "state file path override") - if err := fs.Parse(args); err != nil { - return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput) + if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled { + return code } if *quiet && *explain && !*jsonOut { return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", "--quiet and --explain cannot be used together", exitInvalidInput) diff --git a/core/cli/verify.go b/core/cli/verify.go index 746e880..c07f2f5 100644 --- a/core/cli/verify.go +++ b/core/cli/verify.go @@ -26,8 +26,8 @@ func runVerify(args []string, stdout io.Writer, stderr io.Writer) int { statePathFlag := fs.String("state", "", "state file path override") chainPathFlag := fs.String("path", "", "proof chain path override") - if err := fs.Parse(args); err != nil { - return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput) + if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled { + return code } if !*verifyChain { return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", "--chain is required", exitInvalidInput) diff --git a/core/detect/compiledaction/detector.go b/core/detect/compiledaction/detector.go index 5564b1f..68f6e36 100644 --- a/core/detect/compiledaction/detector.go +++ b/core/detect/compiledaction/detector.go @@ -61,7 +61,12 @@ func (Detector) Detect(_ context.Context, scope detect.Scope, _ detect.Options) continue } - if !(strings.HasPrefix(rel, "workflows/") || strings.HasPrefix(rel, "agent-plans/") || strings.HasSuffix(rel, ".agent-script.json") || strings.HasSuffix(rel, ".ptc.json") || strings.HasPrefix(rel, ".github/workflows/")) { + isCompiledActionPath := strings.HasPrefix(rel, "workflows/") || + strings.HasPrefix(rel, "agent-plans/") || + strings.HasSuffix(rel, ".agent-script.json") || + strings.HasSuffix(rel, ".ptc.json") || + strings.HasPrefix(rel, ".github/workflows/") + if !isCompiledActionPath { continue } diff --git a/core/detect/dependency/detector.go b/core/detect/dependency/detector.go index 3395684..ceae587 100644 --- a/core/detect/dependency/detector.go +++ b/core/detect/dependency/detector.go @@ -113,7 +113,7 @@ func parseRequirements(root string) ([]string, error) { if err != nil { return nil, err } - defer f.Close() + defer func() { _ = f.Close() }() deps := make([]string, 0) scanner := bufio.NewScanner(f) for scanner.Scan() { diff --git a/core/detect/secrets/detector.go b/core/detect/secrets/detector.go index 056820c..7f62d3c 100644 --- a/core/detect/secrets/detector.go +++ b/core/detect/secrets/detector.go @@ -117,7 +117,7 @@ func parseEnvKeys(root, rel string) ([]string, error) { if err != nil { return nil, err } - defer f.Close() + defer func() { _ = f.Close() }() keys := make([]string, 0) scanner := bufio.NewScanner(f) diff --git a/core/github/pr/github_client.go b/core/github/pr/github_client.go index e6b1988..b3e285e 100644 --- a/core/github/pr/github_client.go +++ b/core/github/pr/github_client.go @@ -169,7 +169,7 @@ func (c *GitHubClient) doJSON(ctx context.Context, method, endpoint string, payl req.Header.Set("Content-Type", "application/json") } - resp, err := c.http.Do(req) + resp, err := c.http.Do(req) // #nosec G704 -- request targets GitHub API endpoint assembled from validated base URL and fixed path segments. if err != nil { if attempt < maxAttempts { continue diff --git a/core/github/pr/github_client_test.go b/core/github/pr/github_client_test.go index ffe85c7..d15aaa2 100644 --- a/core/github/pr/github_client_test.go +++ b/core/github/pr/github_client_test.go @@ -36,7 +36,7 @@ func TestGitHubClientListCreateUpdateWithRetry(t *testing.T) { _, _ = w.Write([]byte(`{"number":7,"html_url":"https://example/pr/7","title":"updated","body":"u","head":{"ref":"wrkr-bot/remediation/repo/adhoc/abc"},"base":{"ref":"main"}}`)) default: w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(fmt.Sprintf("unknown route: %s %s", r.Method, r.URL.Path))) + _, _ = fmt.Fprintf(w, "unknown route: %s %s", r.Method, r.URL.Path) } })) defer server.Close() diff --git a/core/manifest/manifest.go b/core/manifest/manifest.go index 5a6f160..98d2d78 100644 --- a/core/manifest/manifest.go +++ b/core/manifest/manifest.go @@ -106,7 +106,7 @@ func Save(path string, m Manifest) error { if err := tmp.Close(); err != nil { return fmt.Errorf("close manifest temp: %w", err) } - if err := os.Rename(tmpPath, path); err != nil { + if err := os.Rename(tmpPath, path); err != nil { // #nosec G703 -- manifest output path is intentionally caller-controlled for local file-based evidence. return fmt.Errorf("commit manifest: %w", err) } return nil diff --git a/core/policy/eval/eval.go b/core/policy/eval/eval.go index ba74cee..3b7b5e3 100644 --- a/core/policy/eval/eval.go +++ b/core/policy/eval/eval.go @@ -115,7 +115,7 @@ func applyRule(rule policy.Rule, findings []model.Finding) (bool, string) { hasCreds = true } } - return !(hasExec && hasCreds), fmt.Sprintf("has_exec=%t,has_credentials=%t", hasExec, hasCreds) + return !hasExec || !hasCreds, fmt.Sprintf("has_exec=%t,has_credentials=%t", hasExec, hasCreds) case "skill_policy_conflicts": count := countType(findings, "skill_policy_conflict") return count == 0, fmt.Sprintf("skill_policy_conflict=%d", count) diff --git a/core/proofemit/signing.go b/core/proofemit/signing.go index 8511bae..5e558d6 100644 --- a/core/proofemit/signing.go +++ b/core/proofemit/signing.go @@ -19,7 +19,7 @@ const envProofKeyID = "WRKR_PROOF_KEY_ID" type keyFile struct { KeyID string `json:"key_id"` PublicKey string `json:"public_key"` - PrivateKey string `json:"private_key"` + PrivateKey string `json:"private_key"` // #nosec G117 -- deterministic local signing key material is intentionally persisted for offline proof signing. } func keyPath(statePath string) string { diff --git a/core/regress/regress.go b/core/regress/regress.go index 66e547f..9aa9ba8 100644 --- a/core/regress/regress.go +++ b/core/regress/regress.go @@ -173,7 +173,7 @@ func SaveBaseline(path string, baseline Baseline) error { if err := tmp.Close(); err != nil { return fmt.Errorf("close baseline temp: %w", err) } - if err := os.Rename(tmpPath, path); err != nil { + if err := os.Rename(tmpPath, path); err != nil { // #nosec G703 -- caller-selected baseline path is intentional for local deterministic artifact output. return fmt.Errorf("commit baseline: %w", err) } return nil diff --git a/core/report/build.go b/core/report/build.go index f5ecc41..30ea185 100644 --- a/core/report/build.go +++ b/core/report/build.go @@ -498,6 +498,7 @@ func buildSections( headlineFacts := []string{ fmt.Sprintf("posture score %.2f (%s)", headline.Score, headline.Grade), fmt.Sprintf("profile status %s at %.2f%%", headline.ComplianceStatus, headline.Compliance), + "profile compliance reflects controls evidenced in the current deterministic scan state", } return []Section{ @@ -571,7 +572,7 @@ func postureImpact(headline Headline) string { func postureAction(headline Headline) string { if strings.EqualFold(headline.ComplianceStatus, "fail") { - return "resolve failing profile controls and rerun scan with the same deterministic inputs" + return "resolve failing or missing controls, regenerate evidence, and rerun scan with the same deterministic inputs" } return "monitor score trend and keep profile compliance above configured minimums" } diff --git a/docs/commands/evidence.md b/docs/commands/evidence.md index c1521a3..3a43825 100644 --- a/docs/commands/evidence.md +++ b/docs/commands/evidence.md @@ -22,6 +22,21 @@ Evidence output directories are fail-closed: - Marker path must be a regular file; symlink or directory markers are blocked. - Unsafe output directory usage returns exit code `8` with error code `unsafe_operation_blocked`. +## Coverage semantics + +`framework_coverage` is computed from proof/evidence present in the scanned state at run time. + +- Coverage percent is an evidence-state signal, not a scanner capability claim. +- Low/0% means controls are currently undocumented or missing in collected evidence. +- Low coverage should trigger remediation work, then another deterministic scan/evidence run. + +Recommended operator actions when coverage is low: + +1. Run `wrkr scan --json` against the intended scope and confirm findings were produced. +2. Review prioritized risk/control gaps with `wrkr report --json`. +3. Implement/remediate missing controls and approvals. +4. Re-run `wrkr scan --json` and `wrkr evidence --frameworks ... --json` to measure updated evidence state. + ## Example ```bash diff --git a/docs/commands/report.md b/docs/commands/report.md index 50f0eda..687bf3e 100644 --- a/docs/commands/report.md +++ b/docs/commands/report.md @@ -30,3 +30,11 @@ wrkr report --md --md-path ./.tmp/wrkr-summary-public.md --template public --sha ``` Expected JSON keys: `status`, `generated_at`, `top_findings`, `total_tools`, `tool_type_breakdown`, `compliance_gap_count`, `summary`, `md_path`, `pdf_path`. + +## Coverage semantics + +Report compliance/posture values are derived from evidence present in the current scan state. + +- Low compliance/coverage in report output indicates control evidence gaps in the scanned snapshot. +- Low compliance/coverage does not imply Wrkr lacks framework support. +- Use report findings as remediation priorities, then rerun deterministic scan/evidence/report commands to confirm improvement. diff --git a/docs/examples/operator-playbooks.md b/docs/examples/operator-playbooks.md index 386603e..79d199e 100644 --- a/docs/examples/operator-playbooks.md +++ b/docs/examples/operator-playbooks.md @@ -34,6 +34,18 @@ wrkr evidence --frameworks eu-ai-act,soc2 --output ./.tmp/evidence --json Check `framework_coverage`, `report_artifacts`, and manifest/chain paths. +`framework_coverage` reflects evidence currently present in scanned state. + +- Low/0% coverage indicates documented control gaps in current evidence. +- Low/0% does not imply Wrkr lacks support for that framework. +- Treat low coverage as an action queue: remediate, rescan, and regenerate evidence. + +Recommended low-coverage response: + +1. Run `wrkr report --top 5 --json` to prioritize the highest-risk missing controls. +2. Complete control implementation or lifecycle approvals for the affected identities/tools. +3. Re-run `wrkr scan --json`, then `wrkr evidence --frameworks ... --json` and compare updated `framework_coverage`. + ### Unsafe output-path handling If output directory is non-empty and not Wrkr-managed, evidence fails closed with exit `8` and `unsafe_operation_blocked`. diff --git a/internal/e2e/cli_contract/cli_contract_e2e_test.go b/internal/e2e/cli_contract/cli_contract_e2e_test.go index 5307c47..45cbc54 100644 --- a/internal/e2e/cli_contract/cli_contract_e2e_test.go +++ b/internal/e2e/cli_contract/cli_contract_e2e_test.go @@ -72,3 +72,36 @@ func TestE2ECLIParseErrorsRemainJSONForFlagOrderingVariants(t *testing.T) { }) } } + +func TestE2EHelpContractMatrixReturnsExit0(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + }{ + {name: "root", args: []string{"--help"}}, + {name: "init", args: []string{"init", "--help"}}, + {name: "scan", args: []string{"scan", "--help"}}, + {name: "evidence", args: []string{"evidence", "--help"}}, + {name: "regress run", args: []string{"regress", "run", "--help"}}, + {name: "report", args: []string{"report", "--help"}}, + {name: "verify", args: []string{"verify", "--help"}}, + {name: "fix", args: []string{"fix", "--help"}}, + {name: "lifecycle", args: []string{"lifecycle", "--help"}}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + var errOut bytes.Buffer + code := cli.Run(tc.args, &out, &errOut) + if code != 0 { + t.Fatalf("expected exit 0 for %v, got %d (stderr=%q)", tc.args, code, errOut.String()) + } + }) + } +} diff --git a/product/dev_guides.md b/product/dev_guides.md index fc0ac01..1863707 100644 --- a/product/dev_guides.md +++ b/product/dev_guides.md @@ -78,6 +78,15 @@ Pin exact versions in all CI workflows and Makefiles. Floating versions (`@lates | Syft | `v1.32.0` | SBOM generation | | Grype | `v0.99.1` | Vulnerability scanning on release artifacts | +### Pinned Tool Update Procedure (Wrkr) + +Use this sequence whenever updating governance-critical tool pins (`gosec`, `golangci-lint`, `govulncheck`, release/signing scanners): + +1. Update the normative table in this document first. +2. Update all enforced CI/workflow/Makefile references in the same change. +3. Run `scripts/check_toolchain_pins.sh` and `make lint-fast` to confirm docs and enforcement are aligned. +4. Never merge a docs-only or workflow-only pin change; pin updates are atomic contract changes. + ### proof Version Tracking Policy `Clyra-AI/proof` is the shared primitive. All downstream SKUs (gait, wrkr, axym) depend on it. diff --git a/scripts/check_toolchain_pins.sh b/scripts/check_toolchain_pins.sh index de606d3..24b2f9b 100755 --- a/scripts/check_toolchain_pins.sh +++ b/scripts/check_toolchain_pins.sh @@ -1,11 +1,157 @@ #!/usr/bin/env bash set -euo pipefail +dev_guides_path="${WRKR_PIN_CHECK_DEV_GUIDES:-product/dev_guides.md}" +targets_raw="${WRKR_PIN_CHECK_TARGETS:-.github/workflows/*.yml Makefile}" +pin_target_files=() + +contains_value() { + local needle="$1" + shift + local item + for item in "$@"; do + if [[ "$item" == "$needle" ]]; then + return 0 + fi + done + return 1 +} + +resolve_pin_target_files() { + local -a patterns=() + read -r -a patterns <<<"$targets_raw" + + shopt -s nullglob + local pattern + local file + for pattern in "${patterns[@]}"; do + if [[ "$pattern" == *"*"* || "$pattern" == *"?"* || "$pattern" == *"["* ]]; then + for file in $pattern; do + if [[ -f "$file" ]]; then + pin_target_files+=("$file") + fi + done + continue + fi + if [[ -f "$pattern" ]]; then + pin_target_files+=("$pattern") + fi + done + shopt -u nullglob + + if [[ ${#pin_target_files[@]} -eq 0 ]]; then + echo "missing pin enforcement targets: $targets_raw" >&2 + exit 3 + fi +} + +read_expected_pin() { + local tool="$1" + awk -F'|' -v tool="$tool" ' + $0 ~ "^\\|[[:space:]]*" tool "[[:space:]]*\\|" { + version = $3 + gsub(/^[[:space:]]+|[[:space:]]+$/, "", version) + gsub(/`/, "", version) + if (version != "") { + print version + found = 1 + exit 0 + } + } + END { + if (!found) { + exit 1 + } + } + ' "$dev_guides_path" +} + +extract_versions_from_file() { + local module="$1" + local file="$2" + awk -v module="$module@" ' + { + line = $0 + while (1) { + idx = index(line, module) + if (idx == 0) { + break + } + rest = substr(line, idx + length(module)) + if (match(rest, /^v[^"[:space:]]+/)) { + print substr(rest, RSTART, RLENGTH) + line = substr(rest, RSTART + RLENGTH) + } else { + break + } + } + } + ' "$file" +} + +check_enforced_pin() { + local tool="$1" + local module="$2" + local expected_version="$3" + local -a observed_versions=() + local -a observed_sources=() + local file + local version + + for file in "${pin_target_files[@]}"; do + while IFS= read -r version; do + if [[ -z "$version" ]]; then + continue + fi + observed_versions+=("$version") + observed_sources+=("$file") + done < <(extract_versions_from_file "$module" "$file") + done + + if [[ ${#observed_versions[@]} -eq 0 ]]; then + echo "missing enforced pin for $tool in targets: ${pin_target_files[*]}" >&2 + exit 3 + fi + + local -a unique_versions=() + for version in "${observed_versions[@]}"; do + if [[ ${#unique_versions[@]} -eq 0 ]] || ! contains_value "$version" "${unique_versions[@]}"; then + unique_versions+=("$version") + fi + done + + if [[ ${#unique_versions[@]} -ne 1 ]]; then + echo "pin mismatch for $tool: expected $expected_version from $dev_guides_path, found multiple versions ${unique_versions[*]} in targets: ${pin_target_files[*]}" >&2 + exit 3 + fi + + if [[ "${unique_versions[0]}" != "$expected_version" ]]; then + local actual_version="${unique_versions[0]}" + local source_path="${pin_target_files[0]}" + local idx + for idx in "${!observed_versions[@]}"; do + if [[ "${observed_versions[$idx]}" == "$actual_version" ]]; then + source_path="${observed_sources[$idx]}" + break + fi + done + echo "pin mismatch for $tool: expected $expected_version from $dev_guides_path, found $actual_version in $source_path" >&2 + exit 3 + fi +} + if [[ ! -f .tool-versions ]]; then echo "missing .tool-versions" >&2 exit 3 fi +if [[ ! -f "$dev_guides_path" ]]; then + echo "missing standards file: $dev_guides_path" >&2 + exit 3 +fi + +resolve_pin_target_files + expected=( "golang 1.25.7" "python 3.13.1" @@ -18,13 +164,26 @@ for line in "${expected[@]}"; do fi done -if grep -Eq '^toolchain go1\.25\.7$' go.mod; then - exit 0 +if grep -Eq '^go 1\.25\.7$' go.mod; then + : +elif grep -Eq '^toolchain go1\.25\.7$' go.mod; then + : +else + echo "go.mod must pin go toolchain version 1.25.7 (toolchain or go directive)" >&2 + exit 3 fi -if grep -Eq '^go 1\.25\.7$' go.mod; then - exit 0 +gosec_expected="$(read_expected_pin "gosec" || true)" +if [[ -z "$gosec_expected" ]]; then + echo "missing expected pin in $dev_guides_path for gosec" >&2 + exit 3 +fi + +golangci_lint_expected="$(read_expected_pin "golangci-lint" || true)" +if [[ -z "$golangci_lint_expected" ]]; then + echo "missing expected pin in $dev_guides_path for golangci-lint" >&2 + exit 3 fi -echo "go.mod must pin go toolchain version 1.25.7 (toolchain or go directive)" >&2 -exit 3 +check_enforced_pin "gosec" "github.com/securego/gosec/v2/cmd/gosec" "$gosec_expected" +check_enforced_pin "golangci-lint" "github.com/golangci/golangci-lint/v2/cmd/golangci-lint" "$golangci_lint_expected" diff --git a/testinfra/hygiene/toolchain_pins_test.go b/testinfra/hygiene/toolchain_pins_test.go new file mode 100644 index 0000000..ecf58ff --- /dev/null +++ b/testinfra/hygiene/toolchain_pins_test.go @@ -0,0 +1,106 @@ +package hygiene + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestCheckToolchainPinsPassesWhenAligned(t *testing.T) { + t.Parallel() + + fixtureRoot := writeToolchainPinFixture(t, fixturePins{ + gosecVersion: "v2.23.0", + golangciLintVersion: "v2.0.1", + }) + _, stderr, err := runToolchainPinCheck(t, fixtureRoot) + if err != nil { + t.Fatalf("expected checker to pass, got err=%v stderr=%q", err, stderr) + } +} + +func TestCheckToolchainPinsFailsOnDrift(t *testing.T) { + t.Parallel() + + fixtureRoot := writeToolchainPinFixture(t, fixturePins{ + gosecVersion: "v2.22.1", + golangciLintVersion: "v2.0.1", + }) + _, stderr, err := runToolchainPinCheck(t, fixtureRoot) + if err == nil { + t.Fatal("expected checker to fail on pin drift") + } + expected := "pin mismatch for gosec: expected v2.23.0 from product/dev_guides.md, found v2.22.1 in .github/workflows/pr.yml" + if !strings.Contains(stderr, expected) { + t.Fatalf("expected deterministic mismatch message %q, got %q", expected, stderr) + } +} + +type fixturePins struct { + gosecVersion string + golangciLintVersion string +} + +func writeToolchainPinFixture(t *testing.T, versions fixturePins) string { + t.Helper() + + root := t.TempDir() + mustWriteFile(t, filepath.Join(root, ".tool-versions"), strings.Join([]string{ + "golang 1.25.7", + "python 3.13.1", + "nodejs 22.14.0", + "", + }, "\n")) + mustWriteFile(t, filepath.Join(root, "go.mod"), "module fixture\n\ngo 1.25.7\n") + mustWriteFile(t, filepath.Join(root, "Makefile"), "lint-fast:\n\t@echo ok\n") + + mustWriteFile(t, filepath.Join(root, "product/dev_guides.md"), strings.Join([]string{ + "| Tool | Pinned Version |", + "|------|----------------|", + "| gosec | `v2.23.0` |", + "| golangci-lint | `v2.0.1` |", + "", + }, "\n")) + + workflow := strings.Join([]string{ + "name: fixture", + "jobs:", + " fast-lane:", + " steps:", + " - run: go install github.com/securego/gosec/v2/cmd/gosec@" + versions.gosecVersion, + " - run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@" + versions.golangciLintVersion, + "", + }, "\n") + mustWriteFile(t, filepath.Join(root, ".github/workflows/pr.yml"), workflow) + + return root +} + +func runToolchainPinCheck(t *testing.T, repoRoot string) (string, string, error) { + t.Helper() + + scriptPath := filepath.Join(mustFindRepoRoot(t), "scripts/check_toolchain_pins.sh") + cmd := exec.Command("bash", scriptPath) + cmd.Dir = repoRoot + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + return stdout.String(), stderr.String(), err +} + +func mustWriteFile(t *testing.T, path string, content string) { + t.Helper() + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", path, err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +}