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
7 changes: 5 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
- Clarified the public `action_paths[*].path_id` contract and aligned docs and contract tests with the shipped deterministic identifier format.
- Clarified scan and report wording so Wrkr's customer-facing output stays explicitly scoped to static posture, risky paths, and offline-verifiable proof.
- Govern-first summaries now highlight ownership quality and ownerless exposure so unresolved or conflicting ownership is explicit in top action paths.
- Updated scan, evidence, campaign, and extension-detector docs plus regression coverage to match the hardened contract and boundary behavior.

### Deprecated

Expand All @@ -40,6 +41,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and

- Deduplicated govern-first `action_paths` so each deterministic action path emits one unique `path_id` row per scan.
- Priority detectors now surface permission and stat failures consistently in scan output so incomplete visibility is explicit.
- Made scan artifact publication transactional so failed late writes no longer leave mixed state, proof, and manifest generations on disk.
- `wrkr campaign aggregate` now rejects non-scan JSON and incomplete artifacts with stable `invalid_input` errors instead of summarizing them as posture evidence.
- Repo-local extension detectors now stay on additive finding surfaces by default and no longer create implicit tool identities, action paths, or regress state.


## Changelog maintenance process
Expand Down Expand Up @@ -75,5 +79,4 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
- `wrkr regress run` now reconciles legacy `v1` baselines created before instance identities when the current identity is equivalent.

### Security

- (none yet)
- Hardened managed output and scan-owned directory ownership checks so forged marker files can no longer authorize destructive reuse of caller-selected paths.
50 changes: 48 additions & 2 deletions core/cli/campaign.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,15 @@ func runCampaignAggregate(args []string, stdout io.Writer, stderr io.Writer) int
if readErr != nil {
return emitError(stderr, jsonRequested || *jsonOut, "runtime_failure", fmt.Sprintf("read scan artifact %s: %v", scanPath, readErr), exitRuntime)
}
var raw map[string]any
if err := json.Unmarshal(payload, &raw); err != nil {
return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", fmt.Sprintf("parse scan artifact %s: %v", scanPath, err), exitInvalidInput)
}
var parsed campaignScanArtifact
if err := json.Unmarshal(payload, &parsed); err != nil {
return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", fmt.Sprintf("parse scan artifact %s: %v", scanPath, err), exitInvalidInput)
}
if artifactErr := validateCampaignScanArtifact(scanPath, parsed); artifactErr != nil {
if artifactErr := validateCampaignScanArtifact(scanPath, raw, parsed); artifactErr != nil {
return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", artifactErr.Error(), exitInvalidInput)
}
inputs = append(inputs, reportcore.CampaignScanInput{
Expand Down Expand Up @@ -158,7 +162,7 @@ func runCampaignAggregate(args []string, stdout io.Writer, stderr io.Writer) int
return exitSuccess
}

func validateCampaignScanArtifact(scanPath string, parsed campaignScanArtifact) error {
func validateCampaignScanArtifact(scanPath string, raw map[string]any, parsed campaignScanArtifact) error {
if strings.TrimSpace(parsed.Status) != "ok" {
return fmt.Errorf("scan artifact %s status must be ok", scanPath)
}
Expand All @@ -175,9 +179,51 @@ func validateCampaignScanArtifact(scanPath string, parsed campaignScanArtifact)
if len(incompleteReasons) > 0 {
return fmt.Errorf("scan artifact %s must be complete; found %s", scanPath, strings.Join(incompleteReasons, ", "))
}
if err := validateCampaignTargetObject(scanPath, "target", raw["target"]); err != nil {
return err
}
sourceManifest, ok := raw["source_manifest"].(map[string]any)
if !ok {
return fmt.Errorf("scan artifact %s missing source_manifest object", scanPath)
}
if err := validateCampaignTargetObject(scanPath, "source_manifest.target", sourceManifest["target"]); err != nil {
return err
}
if repos, ok := sourceManifest["repos"].([]any); !ok || repos == nil {
return fmt.Errorf("scan artifact %s missing source_manifest.repos array", scanPath)
}
if _, ok := raw["inventory"].(map[string]any); !ok {
return fmt.Errorf("scan artifact %s missing inventory object", scanPath)
}
if _, ok := raw["privilege_budget"].(map[string]any); !ok {
return fmt.Errorf("scan artifact %s missing privilege_budget object", scanPath)
}
if _, ok := raw["findings"].([]any); !ok {
return fmt.Errorf("scan artifact %s missing findings array", scanPath)
}
return nil
}

func validateCampaignTargetObject(scanPath string, label string, value any) error {
target, ok := value.(map[string]any)
if !ok {
return fmt.Errorf("scan artifact %s missing %s object", scanPath, label)
}
mode, _ := target["mode"].(string)
if strings.TrimSpace(mode) == "" {
return fmt.Errorf("scan artifact %s missing %s.mode", scanPath, label)
}
rawValue, _ := target["value"].(string)
if campaignTargetRequiresValue(mode) && strings.TrimSpace(rawValue) == "" {
return fmt.Errorf("scan artifact %s missing %s.value", scanPath, label)
}
return nil
}

func campaignTargetRequiresValue(mode string) bool {
return strings.TrimSpace(mode) != "my_setup"
}

type campaignSegmentMetadataFile struct {
SchemaVersion string `yaml:"schema_version"`
Orgs map[string]campaignSegmentOrg `yaml:"orgs"`
Expand Down
65 changes: 65 additions & 0 deletions core/cli/campaign_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,71 @@ func TestCampaignAggregateRejectsArtifactsWithSourceErrors(t *testing.T) {
assertCampaignInvalidInput(t, errOut.Bytes(), "source_errors=1")
}

func TestCampaignAggregateRejectsVersionEnvelope(t *testing.T) {
t.Parallel()

tmp := t.TempDir()
inputPath := filepath.Join(tmp, "version.json")
writeCampaignArtifact(t, inputPath, map[string]any{
"status": "ok",
"version": "devel",
})

var out bytes.Buffer
var errOut bytes.Buffer
code := Run([]string{"campaign", "aggregate", "--input-glob", inputPath, "--json"}, &out, &errOut)
if code != 6 {
t.Fatalf("expected exit 6, got %d stdout=%q stderr=%q", code, out.String(), errOut.String())
}
assertCampaignInvalidInput(t, errOut.Bytes(), "missing target object")
}

func TestCampaignAggregateRejectsReportEnvelope(t *testing.T) {
t.Parallel()

tmp := t.TempDir()
inputPath := filepath.Join(tmp, "report.json")
writeCampaignArtifact(t, inputPath, map[string]any{
"status": "ok",
"generated_at": "2026-03-31T00:00:00Z",
"top_findings": []any{},
"summary": map[string]any{},
})

var out bytes.Buffer
var errOut bytes.Buffer
code := Run([]string{"campaign", "aggregate", "--input-glob", inputPath, "--json"}, &out, &errOut)
if code != 6 {
t.Fatalf("expected exit 6, got %d stdout=%q stderr=%q", code, out.String(), errOut.String())
}
assertCampaignInvalidInput(t, errOut.Bytes(), "missing target object")
}

func TestCampaignAggregateRejectsArtifactMissingInventoryContract(t *testing.T) {
t.Parallel()

tmp := t.TempDir()
inputPath := filepath.Join(tmp, "scan.json")
writeCampaignArtifact(t, inputPath, map[string]any{
"status": "ok",
"target": map[string]any{"mode": "repo", "value": "acme/backend"},
"source_manifest": map[string]any{
"target": map[string]any{"mode": "repo", "value": "acme/backend"},
"repos": []any{},
},
"privilege_budget": map[string]any{},
"findings": []any{},
})

var out bytes.Buffer
var errOut bytes.Buffer
code := Run([]string{"campaign", "aggregate", "--input-glob", inputPath, "--json"}, &out, &errOut)
if code != 6 {
t.Fatalf("expected exit 6, got %d stdout=%q stderr=%q", code, out.String(), errOut.String())
}
assertCampaignInvalidInput(t, errOut.Bytes(), "missing inventory object")
}

func TestCampaignAggregateSuppressesUnknownToSecurityMetricsWithoutReferenceBasis(t *testing.T) {
t.Parallel()

Expand Down
4 changes: 3 additions & 1 deletion core/cli/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -704,9 +704,11 @@ func TestScanRepoAndOrgWithUnreachableGitHubAPIReturnRuntimeFailure(t *testing.T
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

tmp := t.TempDir()
args := append(append([]string{}, tc.args...), "--state", filepath.Join(tmp, "state.json"))
var out bytes.Buffer
var errOut bytes.Buffer
code := Run(tc.args, &out, &errOut)
code := Run(args, &out, &errOut)
if code != 1 {
t.Fatalf("expected exit 1, got %d", code)
}
Expand Down
55 changes: 41 additions & 14 deletions core/cli/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -337,35 +337,62 @@ func runScanWithContext(parentCtx context.Context, args []string, stdout io.Writ
Identities: nextManifest.Identities,
Transitions: transitions,
}
chainPath := lifecycle.ChainPath(statePath)
proofChainPath := proofemit.ChainPath(statePath)
managedSnapshots, snapshotErr := captureManagedArtifacts(
statePath,
manifestPath,
chainPath,
proofChainPath,
proofemit.ChainAttestationPath(proofChainPath),
proofemit.SigningKeyPath(statePath),
artifactPreflight.ReportPath,
artifactPreflight.SARIFPath,
jsonSink.outputPath,
)
if snapshotErr != nil {
return emitScanFailure(snapshotErr)
}
emitRolledBackScanFailure := func(err error) int {
progress.Flush()
return emitRolledBackRuntimeFailure(stderr, jsonRequested || *jsonOut, err, managedSnapshots)
}
emitRolledBackScanError := func(code, message string, exitCode int) int {
progress.Flush()
if restoreErr := restoreManagedArtifacts(managedSnapshots); restoreErr != nil {
return emitError(stderr, jsonRequested || *jsonOut, "runtime_failure", fmt.Sprintf("%s (rollback restore failed: %v)", message, restoreErr), exitRuntime)
}
return emitError(stderr, jsonRequested || *jsonOut, code, message, exitCode)
}

if err := state.Save(statePath, snapshot); err != nil {
return emitScanFailure(err)
return emitRolledBackScanFailure(err)
}
chainPath := lifecycle.ChainPath(statePath)
chain, chainErr := lifecycle.LoadChain(chainPath)
if chainErr != nil {
return emitScanFailure(chainErr)
return emitRolledBackScanFailure(chainErr)
}
for _, transition := range transitions {
if err := lifecycle.AppendTransitionRecord(chain, transition, "lifecycle_transition"); err != nil {
return emitScanFailure(err)
return emitRolledBackScanFailure(err)
}
}
if err := lifecycle.SaveChain(chainPath, chain); err != nil {
return emitScanFailure(err)
return emitRolledBackScanFailure(err)
}
if _, err := proofemit.EmitScan(statePath, now, findings, &inventoryOut, riskReport, profileResult, postureScore, transitions); err != nil {
return emitScanFailure(err)
return emitRolledBackScanFailure(err)
}
proofChain, err := proofemit.LoadChain(proofemit.ChainPath(statePath))
proofChain, err := proofemit.LoadChain(proofChainPath)
if err != nil {
return emitScanFailure(err)
return emitRolledBackScanFailure(err)
}
complianceSummary, err := compliance.BuildRollupSummary(findings, proofChain)
if err != nil {
return emitScanError("policy_schema_violation", err.Error(), exitPolicyViolation)
return emitRolledBackScanError("policy_schema_violation", err.Error(), exitPolicyViolation)
}
if err := manifest.Save(manifestPath, nextManifest); err != nil {
return emitScanFailure(err)
return emitRolledBackScanFailure(err)
}

payload := map[string]any{
Expand Down Expand Up @@ -436,9 +463,9 @@ func runScanWithContext(parentCtx context.Context, args []string, stdout io.Writ
})
if reportErr != nil {
if isArtifactPathError(reportErr) {
return emitScanError("invalid_input", reportErr.Error(), exitInvalidInput)
return emitRolledBackScanError("invalid_input", reportErr.Error(), exitInvalidInput)
}
return emitScanFailure(reportErr)
return emitRolledBackScanFailure(reportErr)
}
scanReportPath = mdOutPath
payload["report"] = map[string]any{
Expand All @@ -450,7 +477,7 @@ func runScanWithContext(parentCtx context.Context, args []string, stdout io.Writ
if *sarifOut {
report := exportsarif.Build(findings, wrkrVersion())
if writeErr := exportsarif.Write(artifactPreflight.SARIFPath, report); writeErr != nil {
return emitScanFailure(writeErr)
return emitRolledBackScanFailure(writeErr)
}
scanSARIFPath = artifactPreflight.SARIFPath
payload["sarif"] = map[string]any{
Expand All @@ -460,7 +487,7 @@ func runScanWithContext(parentCtx context.Context, args []string, stdout io.Writ

if jsonSink.enabled() {
if err := jsonSink.writePayload(payload); err != nil {
return emitScanError("runtime_failure", err.Error(), exitRuntime)
return emitRolledBackScanError("runtime_failure", err.Error(), exitRuntime)
}
progress.Flush()
if *jsonOut {
Expand Down
93 changes: 93 additions & 0 deletions core/cli/scan_extension_boundary_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package cli

import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"testing"
)

func TestScanExtensionFindingDoesNotEmitAuthoritativeSurfaces(t *testing.T) {
t.Parallel()

tmp := t.TempDir()
repoPath := filepath.Join(tmp, "repo", ".wrkr", "detectors")
if err := os.MkdirAll(repoPath, 0o755); err != nil {
t.Fatalf("mkdir extension detector path: %v", err)
}
if err := os.WriteFile(filepath.Join(repoPath, "extensions.json"), []byte(`{
"version": "v1",
"detectors": [
{
"id": "custom-note",
"finding_type": "custom_extension_note",
"tool_type": "custom_detector",
"location": "README.md",
"severity": "low"
}
]
}
`), 0o600); err != nil {
t.Fatalf("write extension descriptor: %v", err)
}

var out bytes.Buffer
var errOut bytes.Buffer
code := Run([]string{"scan", "--path", tmp, "--state", filepath.Join(tmp, "state.json"), "--json"}, &out, &errOut)
if code != exitSuccess {
t.Fatalf("scan failed: code=%d stderr=%s", code, errOut.String())
}

var payload map[string]any
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("parse scan payload: %v", err)
}
findings, ok := payload["findings"].([]any)
if !ok {
t.Fatalf("expected findings array, got %T", payload["findings"])
}
foundCustom := false
for _, item := range findings {
finding, ok := item.(map[string]any)
if !ok {
continue
}
if finding["finding_type"] == "custom_extension_note" && finding["tool_type"] == "custom_detector" {
foundCustom = true
break
}
}
if !foundCustom {
t.Fatalf("expected custom extension finding in raw findings, got %v", findings)
}

inventory, ok := payload["inventory"].(map[string]any)
if !ok {
t.Fatalf("expected inventory object, got %T", payload["inventory"])
}
if tools, ok := inventory["tools"].([]any); ok {
for _, item := range tools {
tool, ok := item.(map[string]any)
if ok && tool["tool_type"] == "custom_detector" {
t.Fatalf("extension finding must not become inventory tool, got %v", tool)
}
}
}
if rows, ok := payload["agent_privilege_map"].([]any); ok {
for _, item := range rows {
row, ok := item.(map[string]any)
if ok && row["tool_type"] == "custom_detector" {
t.Fatalf("extension finding must not become agent privilege row, got %v", row)
}
}
}
if paths, ok := payload["action_paths"].([]any); ok {
for _, item := range paths {
path, ok := item.(map[string]any)
if ok && path["tool_type"] == "custom_detector" {
t.Fatalf("extension finding must not become action path, got %v", path)
}
}
}
}
Loading
Loading