diff --git a/docs/superpowers/specs/2026-08-29-ownership-aware-structural-rules.md b/docs/superpowers/specs/2026-08-29-ownership-aware-structural-rules.md new file mode 100644 index 00000000..c81ce61c --- /dev/null +++ b/docs/superpowers/specs/2026-08-29-ownership-aware-structural-rules.md @@ -0,0 +1,50 @@ +# Ownership-aware structural rules + +## Scope + +This repair improves four existing structural rules without adding exclusions, +waivers, strict profiles, or whole-program analysis. + +## Effect evidence + +A bounded intraprocedural pass classifies function parameters, receivers, +package/global names, local allocations, and simple aliases. It records direct +field/index assignments and recognized mutating calls, then propagates origin +through straightforward assignments and address/reference aliases. Local values +become escaped only when stored into caller-owned/shared state before later +mutation. Returning a freshly constructed value is still local construction. + +Every reportable effect carries: + +- `mutation_target`: `argument`, `receiver`, `global`, or `escaped`; +- `effect_kind`: `persistence`, `network`, `event`, or `shared_state`; +- `origin`: `caller_owned`, `shared`, or `unknown`. + +Local construction evidence may use `local`, `construction`, and +`locally_allocated` internally but does not trigger default structural rules. + +## Rule behavior + +`function.hidden-mutation` reports receiver, argument/alias, global, and escaped +mutation. Locally allocated maps, slices, DTOs, protobufs, and builders returned +from the function remain construction. + +`function.command-query-mix` reports a value-returning query only when effect +evidence is externally observable: persistence writes, network writes, event +publication, persistent cache mutation, or caller/shared mutation. Repository +reads, cache reads, row scanning/hydration, serialization, metrics observation, +protobuf setters, and response construction remain queries. + +`smell.message-chain` requires traversal through several independently owned +collaborators. Repeated calls on one local builder and fluent/generated, +optional/result, JSON, or SQL-hydration chains are exempt. + +`function.inconsistent-return-contract` compares equivalent outcomes while +recognizing deliberate pointer/null/collection optionality, `sql.Null*`, +option/result types, and `(value, found, error)` contracts. + +## Bounds + +Analysis is function-local, linear in parsed assignments/calls/statements, and +uses only direct alias propagation. Unknown calls do not become high-confidence +mutation evidence without an owned target or recognized observable effect. diff --git a/docs/superpowers/specs/2026-08-29-workspace-govulncheck-fixture-evidence.md b/docs/superpowers/specs/2026-08-29-workspace-govulncheck-fixture-evidence.md new file mode 100644 index 00000000..46684556 --- /dev/null +++ b/docs/superpowers/specs/2026-08-29-workspace-govulncheck-fixture-evidence.md @@ -0,0 +1,57 @@ +# Workspace govulncheck and credential fixture evidence + +## Scope + +This repair makes module-dependent vulnerability scanning workspace-aware and +replaces path-only credential fixture demotion with evidence-based +classification. It adds no repository exclusions, waivers, or unrelated rules. + +## Govulncheck + +For a target containing `go.work`, CodeGuard parses active `use` modules and +workspace `replace` directives, validates each module through its `go.mod`, and +runs govulncheck in each module directory. Without `go.work`, the nearest +applicable `go.mod` defines a single module; a repository root without either is +never used as a module working directory. + +Module scans use bounded concurrency and independent timeouts. Their typed +results record success, failure, timeout, and skip status. Successful partial +results survive failures. Vulnerabilities retain advisory, module, package, and +call-stack evidence; advisory-level presentation is deduplicated across modules +without discarding those affected paths. + +Only parsed vulnerabilities become `security.govulncheck` findings. Missing +packages/modules, timeouts, and invocation failures become operational +diagnostics. Required-mode operational failures make scan health nonzero, but +diagnostics are never eligible for a suppression baseline. + +## Credential classification + +Secret detection first extracts candidates, including adjacent or simply +concatenated string literals. Working-tree candidates are classified using +combined path, symbol, syntax, entropy, provider shape, host/account context, +and cross-file reuse evidence. Raw values remain transient and never enter +reports. + +Provider-shaped credentials, private/signing material, meaningful JWTs, +realistic high-entropy values, production-host associations, and reuse outside +test/dev scope remain security findings regardless of their path. Clear fixture +paths plus synthetic symbols, reserved example domains, obvious dummy content, +or explicit fixture construction can establish a likely synthetic fixture only +when stronger credential evidence is absent. + +Classifications are: + +- confirmed: ordinary security finding; +- ambiguous fixture: low-confidence security finding requiring review; +- likely synthetic fixture: informational diagnostic, excluded from baselines. + +Each result exposes non-sensitive evidence codes in JSON and SARIF. Git-history +scanning uses strict candidate detection directly and does not apply working-tree +fixture classification. + +## Reporting invariants + +Diagnostics are structurally separate from findings. They can affect section +and process health, but baseline generation and matching only consume findings. +SARIF results identify diagnostics and include evidence/status properties. diff --git a/internal/codeguard/checks/quality/quality_effect_evidence.go b/internal/codeguard/checks/quality/quality_effect_evidence.go new file mode 100644 index 00000000..85c286f6 --- /dev/null +++ b/internal/codeguard/checks/quality/quality_effect_evidence.go @@ -0,0 +1,268 @@ +package quality + +import ( + "regexp" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" +) + +type mutationEvidence struct { + Target string + Effect string + Origin string + Line int + Detail string +} + +func mutationEvidenceMetadata(evidence mutationEvidence) map[string]string { + return map[string]string{ + "mutation_target": evidence.Target, + "effect_kind": evidence.Effect, + "origin": evidence.Origin, + } +} + +const ( + originLocal = "locally_allocated" + originCaller = "caller_owned" + originShared = "shared" + originUnknown = "unknown" + targetLocal = "local" + targetArgument = "argument" + targetReceiver = "receiver" + targetGlobal = "global" + targetEscaped = "escaped" +) + +var ( + mutationRootPattern = regexp.MustCompile(`\b([A-Za-z_$][\w$]*)\s*(?:\.|->|\[)`) + aliasExprPattern = regexp.MustCompile(`^\s*&?([A-Za-z_$][\w$]*)\s*$`) + goAliasPattern = regexp.MustCompile(`\b([A-Za-z_$][\w$]*)\s*:=\s*&?([A-Za-z_$][\w$]*)\b`) + cppAliasPattern = regexp.MustCompile(`\b[A-Za-z_$][\w$:<>, ]*\s*&\s*([A-Za-z_$][\w$]*)\s*=\s*([A-Za-z_$][\w$]*)\b`) + bodyFieldMutationPattern = regexp.MustCompile(`\b([A-Za-z_$][\w$]*)\s*(?:(?:\.|->)\s*[A-Za-z_$][\w$]*|\[[^]]*\])\s*(?:=\s|\+\+|--|\+=|-=|\*=|/=)`) +) + +func functionMutationEvidence(fn precisionFunction) []mutationEvidence { + origins := map[string]string{} + targets := map[string]string{} + for _, param := range fn.Params { + if param.Name != "" { + origins[param.Name], targets[param.Name] = originCaller, targetArgument + } + } + if fn.ReceiverName != "" { + origins[fn.ReceiverName], targets[fn.ReceiverName] = originCaller, targetReceiver + } + if fn.Receiver != "" { + origins["this"], targets["this"] = originCaller, targetReceiver + } + + locals := localMutationTargets(fn) + for name := range locals { + origins[name], targets[name] = originLocal, targetLocal + } + for _, assignment := range directAssignments(fn) { + name := strings.TrimSpace(assignment.Name) + if name == "" { + continue + } + if source := assignmentAliasSource(fn, assignment); source != "" && assignmentCanReceiveAlias(fn, assignment) { + if origin := origins[source]; origin != "" { + origins[name], targets[name] = origin, targets[source] + } + } + if assignmentLooksLocalAccumulator(fn, assignment) || assignmentLooksLocalBuilder(fn, assignment) || looksLikeLocalObjectAllocation(fn, assignment) { + origins[name], targets[name] = originLocal, targetLocal + } else if origins[name] == "" && strings.Contains(assignment.Expr, "(") { + origins[name], targets[name] = originUnknown, targetEscaped + } + } + for _, pattern := range []*regexp.Regexp{goAliasPattern, cppAliasPattern} { + for _, match := range pattern.FindAllStringSubmatch(fn.Body, -1) { + if origin := origins[match[2]]; origin != "" { + origins[match[1]], targets[match[1]] = origin, targets[match[2]] + } + } + } + + escapedAt := map[string]int{} + escapedNames := map[string]bool{} + for _, statement := range directStatements(fn) { + line := firstNonEmptyString(statement.Raw, statement.Text) + if !lineHasAssignmentOperator(line) { + continue + } + lhs := assignmentLeftHandSide(line) + for name, origin := range origins { + if origin != originLocal || !strings.Contains(line, name) || strings.Contains(lhs, name) { + continue + } + lhsName := strings.TrimSpace(lhs) + if mutationRootPattern.MatchString(lhs) || (aliasExprPattern.MatchString(lhsName) && origins[lhsName] == "") { + escapedAt[name] = statement.Line + } + } + } + for name, origin := range origins { + if origin != originLocal { + continue + } + storePattern := regexp.MustCompile(`\b([A-Za-z_$][\w$]*)(?:\.[A-Za-z_$][\w$]*)?\s*=\s*` + regexp.QuoteMeta(name) + `\b`) + for _, match := range storePattern.FindAllStringSubmatch(fn.Body, -1) { + if origins[match[1]] != originLocal { + escapedNames[name] = true + break + } + } + } + var evidence []mutationEvidence + seen := map[string]struct{}{} + add := func(item mutationEvidence) { + key := item.Target + "|" + item.Effect + "|" + item.Origin + "|" + item.Detail + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + evidence = append(evidence, item) + } + for _, match := range bodyFieldMutationPattern.FindAllStringSubmatch(fn.Body, -1) { + name := match[1] + target, origin := targets[name], origins[name] + if origin == originLocal && escapedNames[name] { + target, origin = targetEscaped, originShared + } else if origin == originLocal || target == "" { + continue + } + add(mutationEvidence{Target: target, Effect: "shared_state", Origin: origin, Line: fn.StartLine, Detail: name}) + } + for _, statement := range directStatements(fn) { + line := firstNonEmptyString(statement.Raw, statement.Text) + if !lineHasAssignmentOperator(line) && !strings.Contains(line, "++") && !strings.Contains(line, "--") { + continue + } + lhs := assignmentLeftHandSide(line) + for _, match := range mutationRootPattern.FindAllStringSubmatch(lhs, -1) { + name := match[1] + target, origin := targets[name], origins[name] + if origin == originLocal { + if escapedAt[name] > 0 && statement.Line > escapedAt[name] { + target, origin = targetEscaped, originShared + } else { + continue + } + } + if target == "" { + target, origin = targetGlobal, originShared + } + add(mutationEvidence{Target: target, Effect: "shared_state", Origin: origin, Line: statement.Line, Detail: name}) + } + } + for _, call := range directCalls(fn) { + effect := observableCallEffect(call.Callee) + targetName := mutationCallTarget(call.Callee) + if isObjectAssignCall(call) { + targetName = firstCallArgName(call) + } + target, origin := targets[targetName], origins[targetName] + if origin == originLocal { + if escapedAt[targetName] > 0 && call.Line > escapedAt[targetName] { + target, origin = targetEscaped, originShared + } else { + continue + } + } + if effect == "" { + if target == "" || !mutatingCallPattern.MatchString(call.Callee) || isConstructionOrHydrationCall(call.Callee) { + continue + } + effect = "shared_state" + } + if target == "" { + target, origin = targetGlobal, originShared + } + add(mutationEvidence{Target: target, Effect: effect, Origin: origin, Line: call.Line, Detail: call.Callee}) + } + return evidence +} + +func assignmentAliasSource(fn precisionFunction, assignment support.ParsedAssignment) string { + if match := aliasExprPattern.FindStringSubmatch(strings.TrimSpace(assignment.Expr)); len(match) == 2 { + return match[1] + } + name := regexp.QuoteMeta(strings.TrimSpace(assignment.Name)) + match := regexp.MustCompile(`\b` + name + `\s*(?::?=)\s*&?([A-Za-z_$][\w$]*)\b`).FindStringSubmatch(assignmentStatement(fn, assignment.Line)) + if len(match) == 2 { + return match[1] + } + return "" +} + +func assignmentDeclaresLocal(fn precisionFunction, assignment support.ParsedAssignment) bool { + statement := assignmentStatement(fn, assignment.Line) + name := regexp.QuoteMeta(strings.TrimSpace(assignment.Name)) + if name == "" { + return false + } + return regexp.MustCompile(`\b`+name+`\s*:=`).MatchString(statement) || + regexp.MustCompile(`(?i)\b(?:const|let|var|auto)\s+`+name+`\b`).MatchString(statement) || + regexp.MustCompile(`\b[A-Za-z_$][\w$:<>, ]*\s*&\s*`+name+`\b`).MatchString(statement) +} + +func assignmentCanReceiveAlias(fn precisionFunction, assignment support.ParsedAssignment) bool { + if assignmentDeclaresLocal(fn, assignment) { + return true + } + name := regexp.QuoteMeta(strings.TrimSpace(assignment.Name)) + return regexp.MustCompile(`(?m)\b(?:var|let|const|auto)\s+` + name + `\b`).MatchString(fn.Body) +} + +func looksLikeLocalObjectAllocation(fn precisionFunction, assignment support.ParsedAssignment) bool { + lowerExpr := strings.ToLower(strings.TrimSpace(assignment.Expr)) + if lowerExpr != "" { + return strings.HasPrefix(lowerExpr, "&") || strings.HasPrefix(lowerExpr, "new ") || + strings.HasPrefix(lowerExpr, "make(") || strings.Contains(lowerExpr, "{}") || + strings.HasPrefix(lowerExpr, "std::") || containsAny(lowerExpr, []string{"builder", "dto", "response", "payload"}) + } + lowerStatement := strings.ToLower(assignmentStatement(fn, assignment.Line)) + name := regexp.QuoteMeta(strings.ToLower(strings.TrimSpace(assignment.Name))) + return regexp.MustCompile(`\b`+name+`\s*:=\s*(?:&|make\s*\()`).MatchString(lowerStatement) || + regexp.MustCompile(`\b`+name+`\s*=\s*new\b`).MatchString(lowerStatement) || + regexp.MustCompile(`\b[A-Za-z_$][\w$:<>, ]+\s+`+name+`\s*\{`).MatchString(lowerStatement) || + regexp.MustCompile(`\b`+name+`\s*:=\s*(?:new|create|build)[a-z0-9_$]*\s*\(`).MatchString(lowerStatement) +} + +func observableCallEffect(callee string) string { + lower := strings.ToLower(callee) + if isConstructionOrHydrationCall(callee) || readCallPattern.MatchString(callee) { + return "" + } + if containsAny(lower, []string{"publish", "emit", "dispatch", "enqueue"}) { + return "event" + } + if containsAny(lower, []string{"http.post", "http.put", "http.patch", "fetch", "axios", ".send", ".upload"}) { + return "network" + } + if containsAny(lower, []string{".save", ".insert", ".update", ".upsert", ".delete", ".exec", ".commit", ".rollback", ".write", ".persist", "cache.set", "cache.put"}) { + return "persistence" + } + return "" +} + +func isConstructionOrHydrationCall(callee string) bool { + lower := strings.ToLower(callee) + return containsAny(lower, []string{ + ".scan", ".setname", ".setid", ".setvalue", ".setfield", "proto.", "protobuf", + "json.marshal", "json.stringify", "serialize", "metrics.", "metric.", "observe", "recordlatency", + "builder.", ".with", ".addfield", ".appendfield", + }) +} + +func firstReportableMutationEvidence(fn precisionFunction) (mutationEvidence, bool) { + for _, evidence := range functionMutationEvidence(fn) { + if evidence.Target != targetLocal { + return evidence, true + } + } + return mutationEvidence{}, false +} diff --git a/internal/codeguard/checks/quality/quality_effect_evidence_test.go b/internal/codeguard/checks/quality/quality_effect_evidence_test.go new file mode 100644 index 00000000..46a9b317 --- /dev/null +++ b/internal/codeguard/checks/quality/quality_effect_evidence_test.go @@ -0,0 +1,33 @@ +package quality + +import ( + "go/ast" + "go/parser" + "go/token" + "testing" +) + +func TestFunctionMutationEvidenceTracksSameLineEscape(t *testing.T) { + source := []byte(`package sample +type State struct{ Value int }; var sharedState *State +func CurrentState() *State { state := &State{}; sharedState = state; state.Value = 1; return state }`) + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "escape.go", source, 0) + if err != nil { + t.Fatal(err) + } + var declaration *ast.FuncDecl + for _, item := range file.Decls { + if fn, ok := item.(*ast.FuncDecl); ok && fn.Name.Name == "CurrentState" { + declaration = fn + } + } + if declaration == nil { + t.Fatal("CurrentState declaration missing") + } + fn := goPrecisionFunction(fset, declaration, source) + evidence, ok := firstReportableMutationEvidence(fn) + if !ok || evidence.Target != targetEscaped || evidence.Origin != originShared { + t.Fatalf("evidence = %#v, ok=%v; assignments=%#v statements=%#v", evidence, ok, fn.Assignments, fn.Statements) + } +} diff --git a/internal/codeguard/checks/quality/quality_precision.go b/internal/codeguard/checks/quality/quality_precision.go index d7862377..42bab90a 100644 --- a/internal/codeguard/checks/quality/quality_precision.go +++ b/internal/codeguard/checks/quality/quality_precision.go @@ -62,6 +62,7 @@ var ( type precisionFunction struct { Name string Receiver string + ReceiverName string StartLine int EndLine int Signature string @@ -120,13 +121,14 @@ func goPrecisionFindings(env support.Context, file string, fset *token.FileSet, func goPrecisionFunction(fset *token.FileSet, fn *ast.FuncDecl, data []byte) precisionFunction { out := precisionFunction{ - Name: fn.Name.Name, - Receiver: goReceiverType(fn), - StartLine: fset.Position(fn.Pos()).Line, - EndLine: fset.Position(fn.End()).Line, - Signature: goResultSignature(fn), - Params: goParsedParams(fn), - Returns: goFuncReturnsValue(fn), + Name: fn.Name.Name, + Receiver: goReceiverType(fn), + ReceiverName: goReceiverName(fn), + StartLine: fset.Position(fn.Pos()).Line, + EndLine: fset.Position(fn.End()).Line, + Signature: goResultSignature(fn), + Params: goParsedParams(fn), + Returns: goFuncReturnsValue(fn), } if fn.Body == nil { return out @@ -165,6 +167,13 @@ func goPrecisionFunction(fset *token.FileSet, fn *ast.FuncDecl, data []byte) pre return out } +func goReceiverName(fn *ast.FuncDecl) string { + if fn.Recv == nil || len(fn.Recv.List) == 0 || len(fn.Recv.List[0].Names) == 0 { + return "" + } + return fn.Recv.List[0].Names[0].Name +} + func goResultSignature(fn *ast.FuncDecl) string { if fn.Type == nil || fn.Type.Results == nil || len(fn.Type.Results.List) == 0 { return "" @@ -559,9 +568,10 @@ func precisionFunctionFindings(env support.Context, file string, fn precisionFun findings = append(findings, precisionWarnFinding(env, functionMixedAbstractionLevelRuleID, file, fn.StartLine, fmt.Sprintf("function %s mixes orchestration calls with low-level infrastructure operations", fn.Name), core.ConfidenceMedium)) } - if commandQueryMix(file, fn) { - findings = append(findings, precisionWarnFinding(env, functionCommandQueryMixRuleID, file, fn.StartLine, - fmt.Sprintf("function %s returns a value while also invoking mutating side-effect operations", fn.Name), core.ConfidenceMedium)) + if evidence, ok := commandQueryEvidence(file, fn); ok { + findings = append(findings, precisionWarnFindingWithMetadata(env, functionCommandQueryMixRuleID, file, fn.StartLine, + fmt.Sprintf("function %s returns a value while performing %s through %s state", fn.Name, evidence.Effect, evidence.Target), + core.ConfidenceMedium, mutationEvidenceMetadata(evidence))) } findings = append(findings, additionalPrecisionFunctionFindings(env, file, fn)...) if !isUIHelperOrMappingContext(file, fn) && !isSeedOrScriptSourcePath(file) && !isFrontendLibraryPath(file) && @@ -696,45 +706,45 @@ func isDomainLevelCall(callee string) bool { return strings.Contains(callee, ".") || queryFunctionPrefixPattern.MatchString(lowered) || len(callee) > 3 } -func commandQueryMix(file string, fn precisionFunction) bool { +func commandQueryEvidence(file string, fn precisionFunction) (mutationEvidence, bool) { if isQualityFixturePath(file) { - return false + return mutationEvidence{}, false } if explicitRepositoryCommandResultContract(file, fn) { - return false + return mutationEvidence{}, false } if isFrameworkOrchestrationBoundary(file, fn) || isReactComponentOrNamedHookBoundary(file, fn) || isUIHelperOrMappingContext(file, fn) || isScriptEntrypoint(file, fn.Name) || isSeedOrScriptSourcePath(file) || isAdapterOrOrchestrationFunction(file, fn) || isPostgresRepositoryPath(file) || isSecurityOrConfigUtilityFunction(file, fn) || explicitMutationName(fn.Name) || isUICommandHelperName(file, fn.Name) || isDomainSideEffectBoundaryName(fn.Name) { - return false + return mutationEvidence{}, false } if !fn.Returns { - return false + return mutationEvidence{}, false } if isAccumulatorBuilderFunctionName(fn.Name) && !hasLikelyExternalMutationCall(fn) { - return false + return mutationEvidence{}, false } if isFactoryHelperName(fn.Name) { - return false + return mutationEvidence{}, false } if isPureComputationHelperName(fn.Name) && !hasLikelyParameterAssignment(fn) && !hasIOOrPersistenceSideEffect(fn) { - return false + return mutationEvidence{}, false } if isUIHelperOrMappingContext(file, fn) && !hasLikelyParameterAssignment(fn) && !predicateHasObviousSideEffect(fn) { - return false + return mutationEvidence{}, false } if isPredicateName(fn.Name) && !hasLikelyParameterAssignment(fn) && !predicateHasObviousSideEffect(fn) { - return false + return mutationEvidence{}, false } name := strings.ToLower(fn.Name) if !queryFunctionPrefixPattern.MatchString(name) && !strings.Contains(fn.Body, "return ") { - return false + return mutationEvidence{}, false } - localTargets := localMutationTargets(fn) - for _, call := range directCalls(fn) { - if mutatingCallPattern.MatchString(call.Callee) && !isLocalMutationCall(call, localTargets) && !isLocalBuilderMutationCall(fn, call) && !isBuilderAccumulatorMutationCall(fn, call) { - return true + for _, evidence := range functionMutationEvidence(fn) { + if evidence.Effect == "persistence" || evidence.Effect == "network" || evidence.Effect == "event" || + (evidence.Effect == "shared_state" && evidence.Target != targetLocal) { + return evidence, true } } - return false + return mutationEvidence{}, false } func errorHandlingFindings(env support.Context, file string, fn precisionFunction) []core.Finding { diff --git a/internal/codeguard/checks/quality/quality_precision_support.go b/internal/codeguard/checks/quality/quality_precision_support.go index 2618694d..f2fe0483 100644 --- a/internal/codeguard/checks/quality/quality_precision_support.go +++ b/internal/codeguard/checks/quality/quality_precision_support.go @@ -11,6 +11,10 @@ type precisionLineRange struct { } func precisionWarnFinding(env support.Context, ruleID string, file string, line int, message string, confidence string) core.Finding { + return precisionWarnFindingWithMetadata(env, ruleID, file, line, message, confidence, nil) +} + +func precisionWarnFindingWithMetadata(env support.Context, ruleID string, file string, line int, message string, confidence string, metadata map[string]string) core.Finding { return env.NewFinding(support.FindingInput{ RuleID: ruleID, Level: "warn", @@ -19,6 +23,7 @@ func precisionWarnFinding(env support.Context, ruleID string, file string, line Column: 1, Message: message, Confidence: confidence, + Metadata: metadata, }) } diff --git a/internal/codeguard/checks/quality/quality_precision_workstreams_cd.go b/internal/codeguard/checks/quality/quality_precision_workstreams_cd.go index d191e29e..528df8da 100644 --- a/internal/codeguard/checks/quality/quality_precision_workstreams_cd.go +++ b/internal/codeguard/checks/quality/quality_precision_workstreams_cd.go @@ -53,9 +53,10 @@ func additionalPrecisionFunctionFindings(env support.Context, file string, fn pr findings = append(findings, precisionWarnFinding(env, namingBehaviorMismatchRuleID, file, fn.StartLine, fmt.Sprintf("function %s name conflicts with observed query/command behavior", fn.Name), core.ConfidenceMedium)) } - if hiddenMutation(file, fn) { - findings = append(findings, precisionWarnFinding(env, functionHiddenMutationRuleID, file, fn.StartLine, - fmt.Sprintf("function %s mutates state without an explicit command-style name", fn.Name), core.ConfidenceMedium)) + if evidence, ok := hiddenMutationEvidence(file, fn); ok { + findings = append(findings, precisionWarnFindingWithMetadata(env, functionHiddenMutationRuleID, file, fn.StartLine, + fmt.Sprintf("function %s mutates state owned by %s through %s", fn.Name, evidence.Target, evidence.Detail), + core.ConfidenceMedium, mutationEvidenceMetadata(evidence))) } if !isReactComponentOrHookBoundary(file, fn) && !isUIHelperOrMappingContext(file, fn) && !isSeedOrScriptSourcePath(file) && !isScriptEntrypoint(file, fn.Name) && @@ -199,40 +200,44 @@ func behaviorMismatch(file string, fn precisionFunction) bool { return false } -func hiddenMutation(file string, fn precisionFunction) bool { +func hiddenMutationEvidence(file string, fn precisionFunction) (mutationEvidence, bool) { if isQualityFixturePath(file) { - return false + return mutationEvidence{}, false } if explicitMutationName(fn.Name) || isUICommandHelperName(file, fn.Name) || isDomainSideEffectBoundaryName(fn.Name) || isFrameworkOrchestrationBoundary(file, fn) || isScriptEntrypoint(file, fn.Name) || isSeedOrScriptSourcePath(file) || isAdapterOrOrchestrationFunction(file, fn) || isSecurityOrConfigUtilityFunction(file, fn) { - return false + return mutationEvidence{}, false } if isUIActionAssemblyFunction(file, fn) { - return false + return mutationEvidence{}, false } if isFactoryHelperName(fn.Name) { - return false + return mutationEvidence{}, false } if isReactComponentOrNamedHookBoundary(file, fn) { - return false + return mutationEvidence{}, false } - mutatesParam := mutatesParameter(fn) - mutatesState := mutatingFunctionEvidence(fn) + evidence, hasEvidence := firstReportableMutationEvidence(fn) + mutatesParam := hasEvidence && evidence.Target == targetArgument + mutatesState := hasEvidence if isUIHelperOrMappingContext(file, fn) && !hasPersistentCollaboratorSideEffect(fn) { - return false + return mutationEvidence{}, false } if isPureComputationHelperName(fn.Name) && !mutatesParam && !hasPersistentCollaboratorSideEffect(fn) { - return false + return mutationEvidence{}, false } if isReactLocalStateBoundary(file, fn) && mutatesState && !mutatesParam && onlyReactHookLocalStateMutation(fn) { - return false + return mutationEvidence{}, false } if isPredicateName(fn.Name) && !mutatesParam && !predicateHasObviousSideEffect(fn) { - return false + return mutationEvidence{}, false } if isAccumulatorBuilderFunctionName(fn.Name) && !hasLikelyExternalMutationCall(fn) && !hasLikelyParameterAssignment(fn) { - return false + return mutationEvidence{}, false } - return mutatesState || mutatesParam + if mutatesState || mutatesParam { + return evidence, true + } + return mutationEvidence{}, false } func predicateHasObviousSideEffect(fn precisionFunction) bool { @@ -359,31 +364,6 @@ func mutatingFunctionEvidence(fn precisionFunction) bool { return false } -func mutatesParameter(fn precisionFunction) bool { - params := map[string]struct{}{} - for _, param := range fn.Params { - if param.Name != "" { - params[param.Name] = struct{}{} - } - } - if len(params) == 0 { - return false - } - for _, statement := range directStatements(fn) { - line := firstNonEmptyString(statement.Raw, statement.Text) - if !lineHasAssignmentOperator(line) { - continue - } - lhs := assignmentLeftHandSide(line) - for _, match := range paramMutationPattern.FindAllStringSubmatch(lhs, -1) { - if _, ok := params[match[1]]; ok { - return true - } - } - } - return false -} - func assignmentLeftHandSide(line string) string { for idx := 0; idx < len(line); idx++ { if line[idx] != '=' { diff --git a/internal/codeguard/checks/quality/quality_smells.go b/internal/codeguard/checks/quality/quality_smells.go index e4fbb136..e852ec94 100644 --- a/internal/codeguard/checks/quality/quality_smells.go +++ b/internal/codeguard/checks/quality/quality_smells.go @@ -22,19 +22,20 @@ const ( ) var ( - pythonClassPattern = regexp.MustCompile(`^(\s*)class\s+([A-Za-z_]\w*)\s*(?:\(([^)]*)\))?\s*:`) - pythonMethodPattern = regexp.MustCompile(`^(\s*)(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(([^)]*)\)\s*:`) - clikeClassPattern = regexp.MustCompile(`(?m)^[ \t]*(?:export[ \t]+)?(?:default[ \t]+)?(?:class|struct)[ \t]+([A-Za-z_$][\w$]*)([^{;]*)\{`) - clikeMethodLinePattern = regexp.MustCompile(`^[ \t]*(?:(?:public|private|protected|static|async|virtual|override|inline|constexpr|const|explicit|final)\s+)*(?:[~A-Za-z_$][\w$:<>,*&\s]+\s+)?([~A-Za-z_$][\w$]*)\s*\(([^)]*)\)\s*(?:const\s*)?(?:override\s*)?(?:noexcept\s*)?\{`) - clikeFieldLinePattern = regexp.MustCompile(`^[ \t]*(?:(?:public|private|protected|static|readonly|mutable|const|let|var|final)\s+)*(?:[A-Za-z_$][\w$:<>,.?*&\[\]]+\s+)?([A-Za-z_$][\w$]*)\s*(?::[^=;]+)?(?:=[^;]+)?;`) - delegateReceiverPattern = regexp.MustCompile(`(?:return\s+)?(?:self|this|[a-zA-Z_]\w*)[.\->]+(_?[A-Za-z_]\w*)[.\->]+[A-Za-z_]\w*\s*\(`) - delegateLocalPattern = regexp.MustCompile(`(?:return\s+)?(_?[A-Za-z_]\w*)[.\->]+[A-Za-z_]\w*\s*\(`) - goKindSwitchPattern = regexp.MustCompile(`(?m)switch\s+[^{}\n]*(?:\.|_)?(?:kind|type|Kind|Type)\b`) - pythonKindBranchPattern = regexp.MustCompile(`(?m)\b(?:if|elif)\s+[^:\n]*(?:\.|_)?(?:kind|type)\b[^:\n]*(?:==| in )`) - scriptKindSwitchPattern = regexp.MustCompile(`(?m)switch\s*\([^)]*(?:\.|_)?(?:kind|type|Kind|Type)\b[^)]*\)`) - cppKindSwitchPattern = regexp.MustCompile(`(?m)switch\s*\([^)]*(?:\.|_)?(?:kind|type|Kind|Type)\b[^)]*\)`) - typeBranchPattern = regexp.MustCompile(`(?m)(?:\.\(type\)|\btypeid\s*\(|\bdynamic_cast\s*<|\binstanceof\b|\btypeof\b|\bisinstance\s*\(|\btype\s*\()`) - fileTraversalPattern = regexp.MustCompile(`\bfile\.`) + pythonClassPattern = regexp.MustCompile(`^(\s*)class\s+([A-Za-z_]\w*)\s*(?:\(([^)]*)\))?\s*:`) + pythonMethodPattern = regexp.MustCompile(`^(\s*)(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(([^)]*)\)\s*:`) + clikeClassPattern = regexp.MustCompile(`(?m)^[ \t]*(?:export[ \t]+)?(?:default[ \t]+)?(?:class|struct)[ \t]+([A-Za-z_$][\w$]*)([^{;]*)\{`) + clikeMethodLinePattern = regexp.MustCompile(`^[ \t]*(?:(?:public|private|protected|static|async|virtual|override|inline|constexpr|const|explicit|final)\s+)*(?:[~A-Za-z_$][\w$:<>,*&\s]+\s+)?([~A-Za-z_$][\w$]*)\s*\(([^)]*)\)\s*(?:const\s*)?(?:override\s*)?(?:noexcept\s*)?\{`) + clikeFieldLinePattern = regexp.MustCompile(`^[ \t]*(?:(?:public|private|protected|static|readonly|mutable|const|let|var|final)\s+)*(?:[A-Za-z_$][\w$:<>,.?*&\[\]]+\s+)?([A-Za-z_$][\w$]*)\s*(?::[^=;]+)?(?:=[^;]+)?;`) + delegateReceiverPattern = regexp.MustCompile(`(?:return\s+)?(?:self|this|[a-zA-Z_]\w*)[.\->]+(_?[A-Za-z_]\w*)[.\->]+[A-Za-z_]\w*\s*\(`) + delegateLocalPattern = regexp.MustCompile(`(?:return\s+)?(_?[A-Za-z_]\w*)[.\->]+[A-Za-z_]\w*\s*\(`) + goKindSwitchPattern = regexp.MustCompile(`(?m)switch\s+[^{}\n]*(?:\.|_)?(?:kind|type|Kind|Type)\b`) + pythonKindBranchPattern = regexp.MustCompile(`(?m)\b(?:if|elif)\s+[^:\n]*(?:\.|_)?(?:kind|type)\b[^:\n]*(?:==| in )`) + scriptKindSwitchPattern = regexp.MustCompile(`(?m)switch\s*\([^)]*(?:\.|_)?(?:kind|type|Kind|Type)\b[^)]*\)`) + cppKindSwitchPattern = regexp.MustCompile(`(?m)switch\s*\([^)]*(?:\.|_)?(?:kind|type|Kind|Type)\b[^)]*\)`) + typeBranchPattern = regexp.MustCompile(`(?m)(?:\.\(type\)|\btypeid\s*\(|\bdynamic_cast\s*<|\binstanceof\b|\btypeof\b|\bisinstance\s*\(|\btype\s*\()`) + fileTraversalPattern = regexp.MustCompile(`\bfile\.`) + messageChainMemberPattern = regexp.MustCompile(`(?:\.|->|\?\.)\s*([A-Za-z_$][\w$]*)`) ) type structuralClass struct { @@ -530,7 +531,7 @@ func messageChainFindings(env support.Context, file string, source string, langu if trimmed == "" || strings.HasPrefix(trimmed, "import ") || strings.HasPrefix(trimmed, "#include") || strings.HasPrefix(trimmed, "package ") { continue } - if chainSeparators(trimmed) >= 4 && !looksLikeAllowedFluentChain(trimmed) && !looksLikeAllowedTraversalChain(trimmed) { + if independentlyOwnedMessageChain(trimmed) && !looksLikeAllowedFluentChain(trimmed) && !looksLikeAllowedTraversalChain(trimmed) { return []core.Finding{precisionWarnFinding(env, smellMessageChainRuleID, file, idx+1, "long message chain reaches through several collaborators; introduce a named query/helper at the boundary", core.ConfidenceMedium)} @@ -539,6 +540,29 @@ func messageChainFindings(env support.Context, file string, source string, langu return nil } +func independentlyOwnedMessageChain(line string) bool { + if chainSeparators(line) < 4 { + return false + } + members := messageChainMemberPattern.FindAllStringSubmatch(line, -1) + if len(members) < 4 { + return false + } + generatedAccessors := 0 + seenMembers := make(map[string]int, len(members)) + for _, member := range members { + name := strings.ToLower(member[1]) + seenMembers[name]++ + if seenMembers[name] >= 2 { + return false + } + if strings.HasPrefix(name, "get") || containsAny(name, []string{"value", "unwrap", "orelse", "valueor", "andthen"}) { + generatedAccessors++ + } + } + return generatedAccessors != len(members) +} + func isDomainSourcePathForMessageChains(file string) bool { normalized := strings.ToLower(strings.ReplaceAll(file, "\\", "/")) return strings.HasPrefix(normalized, "packages/domain/") || @@ -572,7 +596,7 @@ func looksLikeAllowedFluentChain(line string) bool { strings.Contains(lowered, ".with") || strings.Contains(lowered, ".set") || strings.Contains(lowered, "z.") || - containsAny(lowered, []string{".optional", ".nullable", ".default", ".min", ".max", ".regex", ".safeparse", ".parse"}) + containsAny(lowered, []string{".optional", ".nullable", ".default", ".min", ".max", ".regex", ".safeparse", ".parse", ".unwrap", ".valueor", ".orelse"}) } func looksLikeAllowedTraversalChain(line string) bool { @@ -581,9 +605,9 @@ func looksLikeAllowedTraversalChain(line string) bool { return true } for _, marker := range []string{ - "response.", "result.", "payload.", "body.", "json.", "config.", "settings.", + "response.", "result.", "payload.", "body.", "json.", ".json", "config.", "settings.", "process.env", "import.meta.env", "params.", "query.", "headers.", - "row.", "record.", "dto.", "args.", "urlsearchparams", "searchparams.", + "row.", "rows.", "record.", "dto.", "args.", "urlsearchparams", "searchparams.", ".scan(", "contract.", "matter.", "risk.", "project.", "policy.", "file.", "metadata.", "currentpatch.", "existing.", "finding.", "input.", "opts.", ".split(", ".map(", ".filter(", ".at(", diff --git a/internal/codeguard/checks/security/security.go b/internal/codeguard/checks/security/security.go index 0654bee1..6e598046 100644 --- a/internal/codeguard/checks/security/security.go +++ b/internal/codeguard/checks/security/security.go @@ -3,6 +3,7 @@ package security import ( "context" "strings" + "sync" "github.com/devr-tools/codeguard/internal/codeguard/checks/support" "github.com/devr-tools/codeguard/internal/codeguard/core" @@ -11,11 +12,24 @@ import ( // Run is the security section entrypoint; govulncheck only applies to Go // targets, so non-Go languages rely on configured commands instead. func Run(ctx context.Context, env support.Context) core.SectionResult { - return support.RunTargetSection(ctx, env, "security", "Security", securityTargetFindings) + var findings []core.Finding + var diagnostics []core.Diagnostic + for _, target := range env.Config.Targets { + result := securityTargetScan(ctx, env, target) + findings = append(findings, result.findings...) + diagnostics = append(diagnostics, result.diagnostics...) + } + return env.FinalizeSectionWithDiagnostics("security", "Security", findings, diagnostics) +} + +type targetScanResult struct { + findings []core.Finding + diagnostics []core.Diagnostic } -func securityTargetFindings(ctx context.Context, env support.Context, target core.TargetConfig) []core.Finding { +func securityTargetScan(ctx context.Context, env support.Context, target core.TargetConfig) targetScanResult { findings := make([]core.Finding, 0) + diagnostics := make([]core.Diagnostic, 0) // Hardcoded secret/credential detection is language-agnostic and runs for // every target (including TypeScript/JavaScript, which otherwise bypass @@ -33,8 +47,13 @@ func securityTargetFindings(ctx context.Context, env support.Context, target cor })) } if scanner.Enabled() { + var diagnosticMu sync.Mutex findings = append(findings, env.ScanTargetFiles(target, "security-secrets", func(string) bool { return true }, func(file string, data []byte) []core.Finding { - return secretFindingsForFile(env, file, data, scanner) + fileFindings, fileDiagnostics := secretResultsForFile(env, file, data, scanner) + diagnosticMu.Lock() + diagnostics = append(diagnostics, fileDiagnostics...) + diagnosticMu.Unlock() + return fileFindings })...) } @@ -57,9 +76,11 @@ func securityTargetFindings(ctx context.Context, env support.Context, target cor findings = append(findings, commandFindings(ctx, env, target)...) if isGoTarget(target) { - findings = append(findings, govulncheckFindings(ctx, env, target)...) + govulnResult := govulncheckFindings(ctx, env, target) + findings = append(findings, govulnResult.Findings...) + diagnostics = append(diagnostics, govulnResult.Diagnostics...) } - return findings + return targetScanResult{findings: findings, diagnostics: diagnostics} } func commandFindings(ctx context.Context, env support.Context, target core.TargetConfig) []core.Finding { @@ -70,31 +91,21 @@ func commandFindings(ctx context.Context, env support.Context, target core.Targe }) } -func govulncheckFindings(ctx context.Context, env support.Context, target core.TargetConfig) []core.Finding { +func govulncheckFindings(ctx context.Context, env support.Context, target core.TargetConfig) support.GovulncheckResult { mode := strings.ToLower(strings.TrimSpace(env.Config.Checks.SecurityRules.GovulncheckMode)) switch mode { case "", "off": - return nil + return support.GovulncheckResult{} case "auto", "required": - govulnFindings, err := env.RunGovulncheck(ctx, target.Path, env.Config.Checks.SecurityRules.GovulncheckCommand) - if err == nil { - return govulnFindings + result := env.RunGovulncheck(ctx, target.Path, env.Config.Checks.SecurityRules.GovulncheckCommand) + if mode == "auto" { + for i := range result.Diagnostics { + result.Diagnostics[i].Level = "warn" + } } - level := "warn" - if mode == "required" { - level = "fail" - } - return append(govulnFindings, env.NewFinding(support.FindingInput{ - RuleID: "security.govulncheck", - Level: level, - Message: err.Error(), - })) + return result default: - return []core.Finding{env.NewFinding(support.FindingInput{ - RuleID: "security.govulncheck", - Level: "fail", - Message: "govulncheck_mode must be off, auto, or required", - })} + return support.GovulncheckResult{Diagnostics: []core.Diagnostic{{ID: "scan.govulncheck.config", Level: "fail", Kind: "configuration", Message: "govulncheck_mode must be off, auto, or required", Operational: true}}} } } diff --git a/internal/codeguard/checks/security/security_fixture_classification.go b/internal/codeguard/checks/security/security_fixture_classification.go new file mode 100644 index 00000000..6c84f1e4 --- /dev/null +++ b/internal/codeguard/checks/security/security_fixture_classification.go @@ -0,0 +1,75 @@ +package security + +import ( + "regexp" + "strings" +) + +type fixtureClassification string + +const ( + fixtureConfirmed fixtureClassification = "confirmed" + fixtureAmbiguous fixtureClassification = "ambiguous_fixture" + fixtureLikelySynthetic fixtureClassification = "likely_synthetic_fixture" +) + +type fixtureAssessment struct { + Classification fixtureClassification + Evidence []string +} + +var syntheticTokens = []string{"fixture", "example", "dummy", "fake", "mock", "test"} +var fixtureSymbolTokens = []string{"test", "fake", "mock", "fixture"} +var syntheticComponentSeparator = regexp.MustCompile(`[^a-z0-9]+`) + +func classifyFixtureCandidate(path, line string, match Match) fixtureAssessment { + if match.SecretType == "private_key" || match.SecretType == "high_entropy" || match.RuleID == hardcodedCredentialRule { + return fixtureAssessment{Classification: fixtureConfirmed, Evidence: []string{"credential_structure:" + match.SecretType}} + } + if !isFixturePath(path) { + return fixtureAssessment{Classification: fixtureConfirmed, Evidence: []string{"path_scope:non_fixture"}} + } + lower := strings.ToLower(line) + evidence := []string{"path_scope:fixture"} + value := lower + symbol := false + if assignment := strings.IndexAny(lower, "=:"); assignment > 0 { + symbol = containsAny(lower[:assignment], fixtureSymbolTokens) + value = lower[assignment+1:] + } + synthetic := containsSyntheticValueComponent(value) + if strings.Contains(value, "example.com") { + evidence = append(evidence, "host:reserved_example") + } + if symbol { + evidence = append(evidence, "symbol:fixture_convention") + } + if synthetic { + evidence = append(evidence, "value:synthetic_component") + } + if symbol && synthetic { + return fixtureAssessment{Classification: fixtureLikelySynthetic, Evidence: evidence} + } + return fixtureAssessment{Classification: fixtureAmbiguous, Evidence: evidence} +} + +func containsSyntheticValueComponent(value string) bool { + components := syntheticComponentSeparator.Split(strings.ToLower(value), -1) + for _, component := range components { + for _, token := range syntheticTokens { + if component == token { + return true + } + } + } + return false +} + +func containsAny(value string, tokens []string) bool { + for _, token := range tokens { + if strings.Contains(value, token) { + return true + } + } + return false +} diff --git a/internal/codeguard/checks/security/security_fixture_classification_test.go b/internal/codeguard/checks/security/security_fixture_classification_test.go new file mode 100644 index 00000000..308fc1d6 --- /dev/null +++ b/internal/codeguard/checks/security/security_fixture_classification_test.go @@ -0,0 +1,42 @@ +package security + +import "testing" + +func TestClassifyFixtureCandidateRequiresCombinedEvidence(t *testing.T) { + t.Parallel() + tests := []struct { + name, path, line, secretType string + want fixtureClassification + }{ + {"provider credential in test", "testdata/aws.json", `{"accessKey":"AKIA1234567890ABCDEF"}`, "aws_access_key", fixtureConfirmed}, + {"private key in fixture", "fixtures/key.pem", "-----BEGIN PRIVATE KEY-----", "private_key", fixtureConfirmed}, + {"high entropy in test", "src/auth.test.ts", `const token = "k7Jx9PqL2mNvB4wR8tZc3aYd5eHfUgQ1"`, "high_entropy", fixtureConfirmed}, + {"explicit fake symbol and dummy value", "pkg/auth_test.go", `const FakeAuthToken = "fixture-token-for-example.com"`, "named_secret", fixtureLikelySynthetic}, + {"path alone", "testdata/auth.json", `{"password":"hunter2hunter2"}`, "named_secret", fixtureAmbiguous}, + {"production file", "config/auth.json", `{"password":"fixture-password"}`, "named_secret", fixtureConfirmed}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ruleID := hardcodedSecretRule + if tc.secretType == "aws_access_key" { + ruleID = hardcodedCredentialRule + } + got := classifyFixtureCandidate(tc.path, tc.line, Match{RuleID: ruleID, SecretType: tc.secretType}) + if got.Classification != tc.want { + t.Fatalf("classification = %q, want %q (evidence %v)", got.Classification, tc.want, got.Evidence) + } + }) + } +} + +func TestScannerDetectsConcatenatedProviderCredential(t *testing.T) { + t.Parallel() + scanner, issues := BuildScanner(nil) + if len(issues) != 0 { + t.Fatal(issues) + } + matches := scanner.ScanContent(`const token = "AKIA1234" + "567890ABCDEF"`) + if len(matches) != 1 || matches[0].RuleID != hardcodedCredentialRule { + t.Fatalf("matches = %#v, want provider credential", matches) + } +} diff --git a/internal/codeguard/checks/security/security_fixture_demote.go b/internal/codeguard/checks/security/security_fixture_demote.go index 2e1991d1..436be038 100644 --- a/internal/codeguard/checks/security/security_fixture_demote.go +++ b/internal/codeguard/checks/security/security_fixture_demote.go @@ -3,14 +3,10 @@ package security import ( "path/filepath" "strings" - - "github.com/devr-tools/codeguard/internal/codeguard/core" ) -// fixtureDirSegments are path segments that mark conventional test-fixture -// locations; fixtureFileSuffixes are test-file naming conventions. A secret -// hit in either is overwhelmingly synthetic test data, the top false-positive -// source for the secret rules. +// fixtureDirSegments and fixtureFileSuffixes provide one evidence signal for +// classification; a path match alone never exempts or confirms a credential. var ( fixtureFileSuffixes = []string{"_test.go", ".test.ts", "_test.py", ".spec.ts"} fixtureDirSet = map[string]struct{}{ @@ -20,21 +16,6 @@ var ( } ) -// demotableFixtureRules are the secret heuristics subject to fixture-path -// demotion. security.private-key is deliberately excluded: real key material -// is dangerous wherever it lives. -var demotableFixtureRules = map[string]struct{}{ - hardcodedSecretRule: {}, - hardcodedCredentialRule: {}, - highEntropyRule: {}, -} - -// fixtureDemotionEnabled resolves checks.security_rules.demote_fixture_findings, -// which defaults to true when unset. -func fixtureDemotionEnabled(rules core.SecurityRulesConfig) bool { - return rules.DemoteFixtureFindings == nil || *rules.DemoteFixtureFindings -} - // isFixturePath reports whether the file lives in a test/fixture location. func isFixturePath(path string) bool { normalized := strings.ToLower(filepath.ToSlash(path)) @@ -50,19 +31,3 @@ func isFixturePath(path string) bool { } return false } - -// demoteFixtureMatch downgrades a demotable secret match found in a fixture -// path: fail becomes warn (fixture credentials are still worth a warn, never -// silent), confidence drops to low, and the message is suffixed so report -// readers can see why the finding was demoted. -func demoteFixtureMatch(match Match) Match { - if _, ok := demotableFixtureRules[match.RuleID]; !ok { - return match - } - if match.Level == "fail" { - match.Level = "warn" - } - match.Confidence = core.ConfidenceLow - match.Message += " (fixture path)" - return match -} diff --git a/internal/codeguard/checks/security/security_secret_patterns.go b/internal/codeguard/checks/security/security_secret_patterns.go index 6000d3c3..62ce7a6e 100644 --- a/internal/codeguard/checks/security/security_secret_patterns.go +++ b/internal/codeguard/checks/security/security_secret_patterns.go @@ -18,7 +18,7 @@ const ( // whose identifier looks secret-bearing next to a quoted value. It reports at // warn. privateKeyPattern detects PEM key material and reports at fail. var ( - secretPattern = regexp.MustCompile(`(?i)["']?(secret|token|api[_-]?key|password|db[_-]?pass)["']?\s*(?::=|[:=])\s*["']([^"']{8,})["']`) + secretPattern = regexp.MustCompile(`(?i)["']?((?:test|fake|mock|fixture)?(?:secret|token|api[_-]?key|password|db[_-]?pass))["']?\s*(?::=|[:=])\s*["']([^"']{8,})["']`) privateKeyPattern = regexp.MustCompile(`-----BEGIN [A-Z ]*PRIVATE KEY-----`) // quotedLiteralPattern captures whitespace-free quoted literals for the diff --git a/internal/codeguard/checks/security/security_secrets.go b/internal/codeguard/checks/security/security_secrets.go index 2c2ca65c..ec5b5693 100644 --- a/internal/codeguard/checks/security/security_secrets.go +++ b/internal/codeguard/checks/security/security_secrets.go @@ -1,12 +1,15 @@ package security import ( + "regexp" "strings" "github.com/devr-tools/codeguard/internal/codeguard/checks/support" "github.com/devr-tools/codeguard/internal/codeguard/core" ) +var concatenatedLiteralPattern = regexp.MustCompile(`(["'])([^"']*)["']\s*\+\s*["']([^"']*)(["'])`) + // Bounds that keep the scan cheap and resistant to pathological (and untrusted) // input such as minified bundles or deliberately oversized lines. codeguard runs // on PR content it does not control, so these caps are a hardening measure as @@ -32,12 +35,27 @@ func (s Scanner) ScanContent(content string) []Match { line := strings.TrimSuffix(content[start:i], "\r") start = i + 1 if !s.lineAllowed(line) { - matches = append(matches, s.scanLine(lineNo, line)...) + lineMatches := s.scanLine(lineNo, line) + folded := foldConcatenatedLiterals(line) + if folded != line { + foldedMatches := s.scanLine(lineNo, folded) + if len(foldedMatches) > 0 && (len(lineMatches) == 0 || foldedMatches[0].RuleID == hardcodedCredentialRule) { + lineMatches = foldedMatches + } + } + matches = append(matches, lineMatches...) } } return matches } +func foldConcatenatedLiterals(line string) string { + for concatenatedLiteralPattern.MatchString(line) { + line = concatenatedLiteralPattern.ReplaceAllString(line, `$1$2$3$4`) + } + return line +} + // scanLine reports at most one match per line, preferring the highest-confidence // tier: PEM key material and known/custom credential formats fail; the name-based // heuristic warns; the optional entropy pass is last. Overlong lines are scanned @@ -83,20 +101,36 @@ func located(m *Match, lineNo int) []Match { return []Match{*m} } -// secretFindingsForFile runs the scan over a single file and converts matches to -// findings. It applies the path allowlist, skips binary/oversized files, and -// demotes fixture-path matches when the demotion toggle is on. -func secretFindingsForFile(env support.Context, file string, data []byte, scanner Scanner) []core.Finding { +func secretResultsForFile(env support.Context, file string, data []byte, scanner Scanner) ([]core.Finding, []core.Diagnostic) { if scanner.SkipPath(file) || len(data) > maxScanFileBytes || looksBinary(data) { - return nil + return nil, nil } - demote := fixtureDemotionEnabled(env.Config.Checks.SecurityRules) && isFixturePath(file) - matches := scanner.ScanContent(string(data)) + content := string(data) + lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") + matches := scanner.ScanContent(content) findings := make([]core.Finding, 0, len(matches)) + diagnostics := make([]core.Diagnostic, 0) for _, match := range matches { - if demote { - match = demoteFixtureMatch(match) + line := "" + if match.Line > 0 && match.Line <= len(lines) { + line = lines[match.Line-1] + } + assessment := classifyFixtureCandidate(file, line, match) + if assessment.Classification == fixtureLikelySynthetic { + diagnostics = append(diagnostics, core.Diagnostic{ID: "security.credential-fixture", Level: "info", Kind: string(assessment.Classification), Message: "likely synthetic credential fixture", Path: file, Evidence: assessment.Evidence, Metadata: secretMetadata(match)}) + continue + } + if assessment.Classification == fixtureAmbiguous { + match.Level = "warn" + match.Confidence = core.ConfidenceLow + match.Message += " (ambiguous credential-shaped fixture)" + } + metadata := secretMetadata(match) + if metadata == nil { + metadata = map[string]string{} } + metadata["classification"] = string(assessment.Classification) + metadata["classification_evidence"] = strings.Join(assessment.Evidence, ",") findings = append(findings, env.NewFinding(support.FindingInput{ RuleID: match.RuleID, Level: match.Level, @@ -105,10 +139,10 @@ func secretFindingsForFile(env support.Context, file string, data []byte, scanne Column: match.Column, Message: match.Message, Confidence: match.Confidence, - Metadata: secretMetadata(match), + Metadata: metadata, })) } - return findings + return findings, diagnostics } func secretMetadata(match Match) map[string]string { diff --git a/internal/codeguard/checks/support/context.go b/internal/codeguard/checks/support/context.go index f964a78a..c69e0a96 100644 --- a/internal/codeguard/checks/support/context.go +++ b/internal/codeguard/checks/support/context.go @@ -35,6 +35,11 @@ type CPPToolResult struct { Err error } +type GovulncheckResult struct { + Findings []core.Finding + Diagnostics []core.Diagnostic +} + type Context struct { Config core.Config AIEnabled bool @@ -61,24 +66,25 @@ type Context struct { // ParseScriptFile parses one supported non-Go file through the tree-sitter // substrate. It is nil unless parsers.treesitter is "auto"; checks treat // nil (and any error) as "use the native fallback path". - ParseScriptFile func(path string, data []byte, lang ScriptLanguage) (*SyntaxTree, error) - NewFinding func(FindingInput) core.Finding - FinalizeSection func(id string, name string, findings []core.Finding) core.SectionResult - PutArtifact func(core.Artifact) - GetArtifact func(string) (core.Artifact, bool) - CountLines func(data []byte) int - CyclomaticComplexity func(body *ast.BlockStmt) int - TypeName func(expr ast.Expr) string - IsInternalOrCmdFile func(path string) bool - IsCmdFile func(path string) bool - IsPublicPackageFile func(path string) bool - IsSDKFacadeFile func(path string) bool - IsPromptFile func(rel string) bool - RunGovulncheck func(ctx context.Context, dir string, cmdName string) ([]core.Finding, error) - RunCPPFormat func(ctx context.Context, dir string, cfg core.CPPToolingConfig, files []string) CPPToolResult - RunCPPSyntax func(ctx context.Context, dir string, cfg core.CPPToolingConfig) CPPToolResult - RunCommandCheck func(ctx context.Context, dir string, check core.CommandCheckConfig) (string, error) - RunCommandCheckWithEnv func(ctx context.Context, dir string, check core.CommandCheckConfig, env []string) (string, error) - RunDiffCommandCheck func(ctx context.Context, dir string, baseRef string, check core.CommandCheckConfig) (string, error) - NormalizedSeverity func(level string) string + ParseScriptFile func(path string, data []byte, lang ScriptLanguage) (*SyntaxTree, error) + NewFinding func(FindingInput) core.Finding + FinalizeSection func(id string, name string, findings []core.Finding) core.SectionResult + FinalizeSectionWithDiagnostics func(id string, name string, findings []core.Finding, diagnostics []core.Diagnostic) core.SectionResult + PutArtifact func(core.Artifact) + GetArtifact func(string) (core.Artifact, bool) + CountLines func(data []byte) int + CyclomaticComplexity func(body *ast.BlockStmt) int + TypeName func(expr ast.Expr) string + IsInternalOrCmdFile func(path string) bool + IsCmdFile func(path string) bool + IsPublicPackageFile func(path string) bool + IsSDKFacadeFile func(path string) bool + IsPromptFile func(rel string) bool + RunGovulncheck func(ctx context.Context, dir string, cmdName string) GovulncheckResult + RunCPPFormat func(ctx context.Context, dir string, cfg core.CPPToolingConfig, files []string) CPPToolResult + RunCPPSyntax func(ctx context.Context, dir string, cfg core.CPPToolingConfig) CPPToolResult + RunCommandCheck func(ctx context.Context, dir string, check core.CommandCheckConfig) (string, error) + RunCommandCheckWithEnv func(ctx context.Context, dir string, check core.CommandCheckConfig, env []string) (string, error) + RunDiffCommandCheck func(ctx context.Context, dir string, baseRef string, check core.CommandCheckConfig) (string, error) + NormalizedSeverity func(level string) string } diff --git a/internal/codeguard/core/config_rule_types.go b/internal/codeguard/core/config_rule_types.go index acd6c143..da7a462f 100644 --- a/internal/codeguard/core/config_rule_types.go +++ b/internal/codeguard/core/config_rule_types.go @@ -273,12 +273,10 @@ type SecurityRulesConfig struct { TypeScriptTaintMaxDepth int `json:"typescript_taint_max_depth,omitempty" yaml:"typescript_taint_max_depth,omitempty"` LanguageCommands map[string][]CommandCheckConfig `json:"language_commands,omitempty" yaml:"language_commands,omitempty"` Secrets *SecretsRulesConfig `json:"secrets,omitempty" yaml:"secrets,omitempty"` - // DemoteFixtureFindings downgrades hardcoded-secret, hardcoded-credential, - // and high-entropy-string findings located in test/fixture paths (testdata/, - // fixtures/, __fixtures__/, *_test.go, *.test.ts, *_test.py, *.spec.ts): - // fail becomes warn, confidence drops to low, and the message notes the - // demotion. Fixture credentials are still reported — never silenced — but no - // longer fail the scan. Defaults to true when unset. + // DemoteFixtureFindings is retained for configuration compatibility. + // Fixture handling is evidence-based: provider-shaped and high-entropy + // credentials remain strict, ambiguous candidates are low-confidence review + // findings, and only clearly synthetic fixtures become diagnostics. DemoteFixtureFindings *bool `json:"demote_fixture_findings,omitempty" yaml:"demote_fixture_findings,omitempty"` } diff --git a/internal/codeguard/core/report_types.go b/internal/codeguard/core/report_types.go index c9b9e25e..163786df 100644 --- a/internal/codeguard/core/report_types.go +++ b/internal/codeguard/core/report_types.go @@ -41,11 +41,25 @@ type Report struct { } type SectionResult struct { - ID string `json:"id"` - Name string `json:"name"` - Status Status `json:"status"` - Findings []Finding `json:"findings"` - SuppressedCount int `json:"suppressed_count,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Status Status `json:"status"` + Findings []Finding `json:"findings"` + Diagnostics []Diagnostic `json:"diagnostics,omitempty"` + SuppressedCount int `json:"suppressed_count,omitempty"` +} + +// Diagnostic describes scanner operation or informational classification. It +// is deliberately separate from Finding and is never eligible for baselines. +type Diagnostic struct { + ID string `json:"id"` + Level string `json:"level"` + Kind string `json:"kind"` + Message string `json:"message"` + Path string `json:"path,omitempty"` + Operational bool `json:"operational,omitempty"` + Evidence []string `json:"evidence,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` } type Finding struct { diff --git a/internal/codeguard/report/diagnostics_test.go b/internal/codeguard/report/diagnostics_test.go new file mode 100644 index 00000000..72a622f2 --- /dev/null +++ b/internal/codeguard/report/diagnostics_test.go @@ -0,0 +1,27 @@ +package report + +import ( + "bytes" + "strings" + "testing" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +func TestSARIFIncludesDiagnosticsAndMarksOperationalFailure(t *testing.T) { + t.Parallel() + report := core.Report{Sections: []core.SectionResult{{ID: "security", Diagnostics: []core.Diagnostic{{ + ID: "scan.govulncheck.module", Level: "fail", Kind: "infrastructure", Message: "module timed out", Operational: true, + Evidence: []string{"status:timed_out"}, Metadata: map[string]string{"module": "example.com/api"}, + }}}}} + var output bytes.Buffer + if err := writeSARIF(&output, report); err != nil { + t.Fatal(err) + } + text := output.String() + for _, want := range []string{`"ruleId": "scan.govulncheck.module"`, `"executionSuccessful": false`, `"kind": "infrastructure"`, `"status:timed_out"`} { + if !strings.Contains(text, want) { + t.Fatalf("SARIF missing %s:\n%s", want, text) + } + } +} diff --git a/internal/codeguard/report/sarif_builders.go b/internal/codeguard/report/sarif_builders.go index 3b08adf6..28520156 100644 --- a/internal/codeguard/report/sarif_builders.go +++ b/internal/codeguard/report/sarif_builders.go @@ -46,6 +46,26 @@ func buildSARIFResult(finding core.Finding) sarifResult { return result } +func buildSARIFDiagnostic(diagnostic core.Diagnostic) sarifResult { + level := "note" + if diagnostic.Level == "warn" { + level = "warning" + } + if diagnostic.Level == "fail" { + level = "error" + } + result := sarifResult{ + RuleID: diagnostic.ID, + Level: level, + Message: sarifMessage{Text: diagnostic.Message}, + Properties: &sarifResultProperties{Kind: diagnostic.Kind, Evidence: diagnostic.Evidence, Metadata: diagnostic.Metadata}, + } + if diagnostic.Path != "" { + result.Locations = []sarifLocation{{PhysicalLocation: sarifPhysicalLocation{ArtifactLocation: sarifArtifactLocation{URI: diagnostic.Path}}}} + } + return result +} + // sarifPartialFingerprints exposes both codeguard fingerprints to SARIF // consumers. GitHub code scanning deduplicates alerts across commits by // partialFingerprints, so the line-shift-resilient context fingerprint keeps diff --git a/internal/codeguard/report/sarif_result_types.go b/internal/codeguard/report/sarif_result_types.go index 641b638a..ce3f4fe7 100644 --- a/internal/codeguard/report/sarif_result_types.go +++ b/internal/codeguard/report/sarif_result_types.go @@ -12,7 +12,10 @@ type sarifResult struct { // sarifResultProperties is the SARIF result property bag. Confidence carries // the finding's confidence ("high", "medium", "low") when the check set one. type sarifResultProperties struct { - Confidence string `json:"confidence,omitempty"` + Confidence string `json:"confidence,omitempty"` + Kind string `json:"kind,omitempty"` + Evidence []string `json:"evidence,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` } type sarifMessage struct { diff --git a/internal/codeguard/report/write.go b/internal/codeguard/report/write.go index c08a5e08..b47c6d12 100644 --- a/internal/codeguard/report/write.go +++ b/internal/codeguard/report/write.go @@ -107,13 +107,24 @@ func writeSARIF(w io.Writer, report core.Report) error { } results = append(results, buildSARIFResult(finding)) } + for _, diagnostic := range section.Diagnostics { + results = append(results, buildSARIFDiagnostic(diagnostic)) + } } sort.Slice(sarifRules, func(i, j int) bool { return sarifRules[i].ID < sarifRules[j].ID }) // invocation records that codeguard ran, so a consumer can attribute the // SARIF file to a specific run (SOC 3 monitoring / audit trail). The analysis // completing successfully is independent of whether findings were reported. - invocation := map[string]any{"executionSuccessful": true} + executionSuccessful := true + for _, section := range report.Sections { + for _, diagnostic := range section.Diagnostics { + if diagnostic.Operational && diagnostic.Level == "fail" { + executionSuccessful = false + } + } + } + invocation := map[string]any{"executionSuccessful": executionSuccessful} if report.GeneratedAt != "" { invocation["endTimeUtc"] = report.GeneratedAt } diff --git a/internal/codeguard/runner/checks/checks.go b/internal/codeguard/runner/checks/checks.go index a5ce815b..f6e324f1 100644 --- a/internal/codeguard/runner/checks/checks.go +++ b/internal/codeguard/runner/checks/checks.go @@ -184,6 +184,9 @@ func buildCheckContext(ctx context.Context, sc runnersupport.Context) checkSuppo FinalizeSection: func(id string, name string, findings []core.Finding) core.SectionResult { return runnersupport.FinalizeSection(sc, id, name, findings) }, + FinalizeSectionWithDiagnostics: func(id string, name string, findings []core.Finding, diagnostics []core.Diagnostic) core.SectionResult { + return runnersupport.FinalizeSectionWithDiagnostics(sc, id, name, findings, diagnostics) + }, PutArtifact: func(artifact core.Artifact) { sc.Artifacts.Put(artifact) }, diff --git a/internal/codeguard/runner/checks/govulncheck_callback.go b/internal/codeguard/runner/checks/govulncheck_callback.go index ae62017f..25e2ca07 100644 --- a/internal/codeguard/runner/checks/govulncheck_callback.go +++ b/internal/codeguard/runner/checks/govulncheck_callback.go @@ -3,13 +3,14 @@ package checks import ( "context" - "github.com/devr-tools/codeguard/internal/codeguard/core" + checkSupport "github.com/devr-tools/codeguard/internal/codeguard/checks/support" govulncheckrunner "github.com/devr-tools/codeguard/internal/codeguard/runner/govulncheck" runnersupport "github.com/devr-tools/codeguard/internal/codeguard/runner/support" ) -func govulncheckCallback(sc runnersupport.Context) func(context.Context, string, string) ([]core.Finding, error) { - return func(ctx context.Context, dir, command string) ([]core.Finding, error) { - return govulncheckrunner.Run(ctx, dir, command, sc) +func govulncheckCallback(sc runnersupport.Context) func(context.Context, string, string) checkSupport.GovulncheckResult { + return func(ctx context.Context, dir, command string) checkSupport.GovulncheckResult { + findings, diagnostics := govulncheckrunner.RunWorkspace(ctx, dir, command, sc) + return checkSupport.GovulncheckResult{Findings: findings, Diagnostics: diagnostics} } } diff --git a/internal/codeguard/runner/govulncheck/govulncheck.go b/internal/codeguard/runner/govulncheck/govulncheck.go index c1e1bc49..5c80286a 100644 --- a/internal/codeguard/runner/govulncheck/govulncheck.go +++ b/internal/codeguard/runner/govulncheck/govulncheck.go @@ -2,7 +2,10 @@ package govulncheck import ( "context" + "errors" "fmt" + "os/exec" + "regexp" "strings" "github.com/devr-tools/codeguard/internal/codeguard/core" @@ -21,6 +24,86 @@ const defaultCommand = "govulncheck" // malicious tool cannot exhaust memory. const maxOutputBytes = 64 << 20 // 64 MiB +var advisoryPattern = regexp.MustCompile(`GO-[0-9]{4}-[0-9]+`) + +func RunWorkspace(ctx context.Context, dir string, cmdName string, sc runnersupport.Context) ([]core.Finding, []core.Diagnostic) { + workspace, err := DiscoverWorkspace(dir) + if err != nil { + return nil, []core.Diagnostic{{ID: "scan.govulncheck.workspace", Level: "fail", Kind: "infrastructure", Message: err.Error(), Operational: true}} + } + if len(workspace.Modules) == 0 { + return nil, []core.Diagnostic{{ID: "scan.govulncheck.module-unavailable", Level: "fail", Kind: "infrastructure", Message: "no Go module is available for govulncheck", Path: dir, Operational: true}} + } + result := ScanWorkspace(ctx, workspace, ScanOptions{Execute: func(moduleCtx context.Context, module Module) ([]Vulnerability, error) { + findings, runErr := Run(moduleCtx, module.Dir, cmdName, sc) + vulnerabilities := make([]Vulnerability, 0, len(findings)) + for _, finding := range findings { + id := advisoryPattern.FindString(finding.Message) + if id == "" { + id = finding.Message + } + callStack := []string(nil) + if raw := finding.Metadata["call_stack"]; raw != "" { + callStack = strings.Split(raw, " -> ") + } + vulnerabilities = append(vulnerabilities, Vulnerability{AdvisoryID: id, Package: finding.Metadata["package"], CallStack: callStack}) + } + return vulnerabilities, runErr + }}) + findings := make([]core.Finding, 0, len(result.Vulnerabilities)) + for _, vulnerability := range result.Vulnerabilities { + findings = append(findings, runnersupport.NewFinding(sc, runnersupport.FindingInput{RuleID: "security.govulncheck", Level: "fail", Message: "govulncheck reported " + vulnerability.AdvisoryID, Metadata: map[string]string{"advisory_id": vulnerability.AdvisoryID, "affected_modules": occurrenceField(vulnerability.Occurrences, func(o Occurrence) string { return o.Module }), "affected_packages": occurrenceField(vulnerability.Occurrences, func(o Occurrence) string { return o.Package }), "call_stacks": occurrenceField(vulnerability.Occurrences, func(o Occurrence) string { return strings.Join(o.CallStack, " -> ") })}})) + } + diagnostics := make([]core.Diagnostic, 0) + for _, module := range result.Modules { + if module.Status == ModuleSucceeded { + diagnostics = append(diagnostics, core.Diagnostic{ID: "scan.govulncheck.module-status", Level: "info", Kind: "scan_status", Message: "govulncheck succeeded for module " + module.Module.ModulePath, Path: module.Module.Dir, Metadata: map[string]string{"module": module.Module.ModulePath, "status": string(module.Status)}}) + continue + } + message := "govulncheck failed for module " + module.Module.ModulePath + if module.Status == ModuleTimedOut { + message = "govulncheck timed out for module " + module.Module.ModulePath + } + if module.Err != nil { + message += ": " + module.Err.Error() + } + diagnosticID, failureKind := classifyModuleFailure(module) + diagnostics = append(diagnostics, core.Diagnostic{ID: diagnosticID, Level: "fail", Kind: "infrastructure", Message: message, Path: module.Module.Dir, Operational: true, Evidence: []string{"failure_kind:" + failureKind}, Metadata: map[string]string{"module": module.Module.ModulePath, "status": string(module.Status), "failure_kind": failureKind}}) + } + return findings, diagnostics +} + +func classifyModuleFailure(module ModuleResult) (string, string) { + if module.Status == ModuleTimedOut { + return "scan.govulncheck.timeout", "timeout" + } + message := "" + if module.Err != nil { + message = strings.ToLower(module.Err.Error()) + } + if strings.Contains(message, "no packages") || strings.Contains(message, "package") && strings.Contains(message, "unavailable") || strings.Contains(message, "module") && strings.Contains(message, "unavailable") { + return "scan.govulncheck.module-unavailable", "module_or_package_unavailable" + } + return "scan.govulncheck.execution-failure", "tool_execution_failure" +} + +func occurrenceField(occurrences []Occurrence, value func(Occurrence) string) string { + values := make([]string, 0, len(occurrences)) + seen := map[string]struct{}{} + for _, occurrence := range occurrences { + field := value(occurrence) + if field == "" { + continue + } + if _, ok := seen[field]; ok { + continue + } + seen[field] = struct{}{} + values = append(values, field) + } + return strings.Join(values, ",") +} + func Run(ctx context.Context, dir string, cmdName string, sc runnersupport.Context) ([]core.Finding, error) { cmdName = strings.TrimSpace(cmdName) if cmdName == "" { @@ -36,13 +119,14 @@ func Run(ctx context.Context, dir string, cmdName string, sc runnersupport.Conte } text, err := runnersupport.RunLimitedCommand(ctx, dir, maxOutputBytes, cmdName, "./...") parsed := parseOutput(text, sc) - if len(parsed) > 0 { - return parsed, nil - } if err != nil { - return nil, fmt.Errorf("govulncheck integration failed: %w", err) + var exitErr *exec.ExitError + if len(parsed) > 0 && errors.As(err, &exitErr) && exitErr.ExitCode() == 3 { + return parsed, nil + } + return parsed, fmt.Errorf("govulncheck integration failed: %w", err) } - return nil, nil + return parsed, nil } func parseOutput(output string, sc runnersupport.Context) []core.Finding { @@ -51,6 +135,7 @@ func parseOutput(output string, sc runnersupport.Context) []core.Finding { current := "" foundIn := "" fixedIn := "" + trace := make([]string, 0) flush := func() { if current == "" { return @@ -62,12 +147,20 @@ func parseOutput(output string, sc runnersupport.Context) []core.Finding { if fixedIn != "" { message += " fixed in " + fixedIn } + metadata := map[string]string{"advisory_id": advisoryPattern.FindString(current)} + if foundIn != "" { + metadata["package"] = foundIn + } + if len(trace) > 0 { + metadata["call_stack"] = strings.Join(trace, " -> ") + } findings = append(findings, runnersupport.NewFinding(sc, runnersupport.FindingInput{ - RuleID: "security.govulncheck", - Level: "fail", - Message: message, + RuleID: "security.govulncheck", + Level: "fail", + Message: message, + Metadata: metadata, })) - current, foundIn, fixedIn = "", "", "" + current, foundIn, fixedIn, trace = "", "", "", trace[:0] } for _, line := range lines { line = strings.TrimSpace(line) @@ -81,6 +174,8 @@ func parseOutput(output string, sc runnersupport.Context) []core.Finding { fixedIn = strings.TrimSpace(strings.TrimPrefix(line, "Fixed in:")) case line == "": flush() + case current != "": + trace = append(trace, line) } } flush() diff --git a/internal/codeguard/runner/govulncheck/workspace.go b/internal/codeguard/runner/govulncheck/workspace.go new file mode 100644 index 00000000..64825f16 --- /dev/null +++ b/internal/codeguard/runner/govulncheck/workspace.go @@ -0,0 +1,145 @@ +package govulncheck + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +type Module struct { + Dir string + ModulePath string +} + +type Workspace struct { + Root string + RootModule string + Modules []Module + Replacements map[string]string +} + +// DiscoverWorkspace resolves active Go modules without invoking project tools. +func DiscoverWorkspace(start string) (Workspace, error) { + abs, err := filepath.Abs(start) + if err != nil { + return Workspace{}, err + } + workPath := findUp(abs, "go.work") + if workPath != "" { + return parseWorkspace(workPath) + } + modPath := findUp(abs, "go.mod") + if modPath == "" { + return Workspace{Root: abs, Replacements: map[string]string{}}, nil + } + modulePath, err := readModulePath(modPath) + if err != nil { + return Workspace{}, err + } + dir := filepath.Dir(modPath) + return Workspace{Root: dir, RootModule: modulePath, Modules: []Module{{Dir: dir, ModulePath: modulePath}}, Replacements: map[string]string{}}, nil +} + +func findUp(start, name string) string { + dir := start + if info, err := os.Stat(dir); err == nil && !info.IsDir() { + dir = filepath.Dir(dir) + } + for { + candidate := filepath.Join(dir, name) + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return candidate + } + parent := filepath.Dir(dir) + if parent == dir { + return "" + } + dir = parent + } +} + +func parseWorkspace(path string) (Workspace, error) { + root := filepath.Dir(path) + data, err := os.ReadFile(path) // #nosec G304 -- path is the discovered go.work file, not repository content. + if err != nil { + return Workspace{}, err + } + uses, replacements := parseWorkDirectives(string(data), root) + modules := make([]Module, 0, len(uses)) + for _, use := range uses { + modFile := filepath.Join(use, "go.mod") + modulePath, readErr := readModulePath(modFile) + if readErr != nil { + return Workspace{}, fmt.Errorf("workspace module %s: %w", use, readErr) + } + modules = append(modules, Module{Dir: use, ModulePath: modulePath}) + } + sort.Slice(modules, func(i, j int) bool { return modules[i].ModulePath < modules[j].ModulePath }) + rootModule := "" + if modulePath, readErr := readModulePath(filepath.Join(root, "go.mod")); readErr == nil { + rootModule = modulePath + } + return Workspace{Root: root, RootModule: rootModule, Modules: modules, Replacements: replacements}, nil +} + +func parseWorkDirectives(content, root string) ([]string, map[string]string) { + var uses []string + replacements := map[string]string{} + inUse := false + inReplace := false + for scanner := bufio.NewScanner(strings.NewReader(content)); scanner.Scan(); { + line := strings.TrimSpace(strings.SplitN(scanner.Text(), "//", 2)[0]) + switch { + case line == "use (": + inUse = true + case inUse && line == ")": + inUse = false + case line == "replace (": + inReplace = true + case inReplace && line == ")": + inReplace = false + case inUse && line != "": + uses = append(uses, resolveWorkPath(root, strings.Fields(line)[0])) + case strings.HasPrefix(line, "use "): + uses = append(uses, resolveWorkPath(root, strings.Fields(strings.TrimPrefix(line, "use "))[0])) + case inReplace && strings.Contains(line, "=>"): + addReplacement(replacements, root, line) + case strings.HasPrefix(line, "replace ") && strings.Contains(line, "=>"): + addReplacement(replacements, root, strings.TrimSpace(strings.TrimPrefix(line, "replace "))) + } + } + return uses, replacements +} + +func addReplacement(replacements map[string]string, root, directive string) { + parts := strings.SplitN(directive, "=>", 2) + oldFields, newFields := strings.Fields(parts[0]), strings.Fields(parts[1]) + if len(oldFields) > 0 && len(newFields) > 0 { + replacements[oldFields[0]] = resolveWorkPath(root, newFields[0]) + } +} + +func resolveWorkPath(root, value string) string { + value = strings.Trim(value, "\"") + if filepath.IsAbs(value) || (!strings.HasPrefix(value, ".") && strings.Contains(value, ".")) { + return filepath.Clean(value) + } + return filepath.Clean(filepath.Join(root, filepath.FromSlash(value))) +} + +func readModulePath(path string) (string, error) { + data, err := os.ReadFile(path) // #nosec G304 -- path is a go.mod resolved from the workspace root/use directives. + if err != nil { + return "", err + } + for scanner := bufio.NewScanner(strings.NewReader(string(data))); scanner.Scan(); { + fields := strings.Fields(scanner.Text()) + if len(fields) >= 2 && fields[0] == "module" { + return strings.Trim(fields[1], "\""), nil + } + } + return "", fmt.Errorf("module directive missing in %s", path) +} diff --git a/internal/codeguard/runner/govulncheck/workspace_scan.go b/internal/codeguard/runner/govulncheck/workspace_scan.go new file mode 100644 index 00000000..19c8e178 --- /dev/null +++ b/internal/codeguard/runner/govulncheck/workspace_scan.go @@ -0,0 +1,114 @@ +package govulncheck + +import ( + "context" + "errors" + "sort" + "sync" + "time" +) + +type ModuleStatus string + +const ( + ModuleSucceeded ModuleStatus = "succeeded" + ModuleFailed ModuleStatus = "failed" + ModuleTimedOut ModuleStatus = "timed_out" + ModuleSkipped ModuleStatus = "skipped" +) + +type Occurrence struct { + Module string + Package string + CallStack []string +} + +type Vulnerability struct { + AdvisoryID string + Package string + CallStack []string + Occurrences []Occurrence +} + +type ModuleResult struct { + Module Module + Status ModuleStatus + Err error +} + +type ScanResult struct { + Vulnerabilities []Vulnerability + Modules []ModuleResult +} + +type ScanOptions struct { + Concurrency int + ModuleTimeout time.Duration + Execute func(context.Context, Module) ([]Vulnerability, error) +} + +func ScanWorkspace(ctx context.Context, workspace Workspace, opts ScanOptions) ScanResult { + if opts.Concurrency < 1 { + opts.Concurrency = 4 + } + if opts.ModuleTimeout <= 0 { + opts.ModuleTimeout = 2 * time.Minute + } + result := ScanResult{Modules: make([]ModuleResult, len(workspace.Modules))} + sem := make(chan struct{}, opts.Concurrency) + var wg sync.WaitGroup + var mu sync.Mutex + all := make([]Vulnerability, 0) + for i, module := range workspace.Modules { + wg.Add(1) + go func() { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + moduleCtx, cancel := context.WithTimeout(ctx, opts.ModuleTimeout) + defer cancel() + vulns, err := opts.Execute(moduleCtx, module) + status := ModuleSucceeded + if err != nil { + status = ModuleFailed + if errors.Is(err, context.DeadlineExceeded) || errors.Is(moduleCtx.Err(), context.DeadlineExceeded) { + status = ModuleTimedOut + } + } + mu.Lock() + result.Modules[i] = ModuleResult{Module: module, Status: status, Err: err} + for _, vulnerability := range vulns { + vulnerability.Occurrences = append(vulnerability.Occurrences, Occurrence{Module: module.ModulePath, Package: vulnerability.Package, CallStack: append([]string(nil), vulnerability.CallStack...)}) + all = append(all, vulnerability) + } + mu.Unlock() + }() + } + wg.Wait() + result.Vulnerabilities = deduplicateVulnerabilities(all) + return result +} + +func deduplicateVulnerabilities(input []Vulnerability) []Vulnerability { + byAdvisory := make(map[string]*Vulnerability) + for _, vulnerability := range input { + current := byAdvisory[vulnerability.AdvisoryID] + if current == nil { + vulnerabilityCopy := vulnerability + vulnerabilityCopy.Occurrences = nil + current = &vulnerabilityCopy + byAdvisory[vulnerability.AdvisoryID] = current + } + current.Occurrences = append(current.Occurrences, vulnerability.Occurrences...) + } + keys := make([]string, 0, len(byAdvisory)) + for key := range byAdvisory { + keys = append(keys, key) + } + sort.Strings(keys) + out := make([]Vulnerability, 0, len(keys)) + for _, key := range keys { + out = append(out, *byAdvisory[key]) + } + return out +} diff --git a/internal/codeguard/runner/govulncheck/workspace_test.go b/internal/codeguard/runner/govulncheck/workspace_test.go new file mode 100644 index 00000000..43ac7657 --- /dev/null +++ b/internal/codeguard/runner/govulncheck/workspace_test.go @@ -0,0 +1,147 @@ +package govulncheck + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + runnersupport "github.com/devr-tools/codeguard/internal/codeguard/runner/support" +) + +func TestRunPreservesPartialFindingsAndPropagatesCommandError(t *testing.T) { + dir := t.TempDir() + command := filepath.Join(dir, defaultCommand) + writeTestFile(t, dir, defaultCommand, "#!/bin/sh\necho 'Vulnerability #1: GO-2099-0099'\necho ' Found in: example.com/partial@v1.0.0'\necho ''\nexit 2\n") + if err := os.Chmod(command, 0o700); err != nil { // #nosec G302 -- executable test fixture must have an execute bit. + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + findings, err := Run(context.Background(), dir, defaultCommand, runnersupport.Context{}) + if len(findings) != 1 { + t.Fatalf("findings = %#v, want partial vulnerability", findings) + } + if err == nil { + t.Fatal("error = nil, want command failure alongside partial findings") + } +} + +func TestRunAcceptsGovulncheckVulnerabilityExitCode(t *testing.T) { + dir := t.TempDir() + command := filepath.Join(dir, defaultCommand) + writeTestFile(t, dir, defaultCommand, "#!/bin/sh\necho 'Vulnerability #1: GO-2099-0099'\necho ' Found in: example.com/affected@v1.0.0'\necho ''\nexit 3\n") + if err := os.Chmod(command, 0o700); err != nil { // #nosec G302 -- executable test fixture must have an execute bit. + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + findings, err := Run(context.Background(), dir, defaultCommand, runnersupport.Context{}) + if err != nil { + t.Fatalf("Run() error = %v, want vulnerability exit accepted", err) + } + if len(findings) != 1 { + t.Fatalf("findings = %#v, want vulnerability", findings) + } +} + +func TestDiscoverModulesFromWorkspaceWithoutRootModule(t *testing.T) { + t.Parallel() + root := t.TempDir() + writeTestFile(t, root, "go.work", "go 1.24\nuse (\n\t./services/api\n\t./libs/auth\n)\nreplace (\nexample.com/old => ./libs/replacement\n)\n") + writeTestFile(t, root, "services/api/go.mod", "module example.com/api\n\ngo 1.24\n") + writeTestFile(t, root, "libs/auth/go.mod", "module example.com/auth\n\ngo 1.24\n") + writeTestFile(t, root, "libs/replacement/go.mod", "module example.com/replacement\n\ngo 1.24\n") + + workspace, err := DiscoverWorkspace(root) + if err != nil { + t.Fatalf("DiscoverWorkspace() error = %v", err) + } + if workspace.RootModule != "" { + t.Fatalf("RootModule = %q, want empty", workspace.RootModule) + } + if len(workspace.Modules) != 2 { + t.Fatalf("Modules = %#v, want two active use modules", workspace.Modules) + } + if got := workspace.Modules[0].ModulePath; got != "example.com/api" { + t.Errorf("first module = %q, want deterministic example.com/api", got) + } + if got := workspace.Replacements["example.com/old"]; got != filepath.Join(root, "libs/replacement") { + t.Errorf("replacement = %q", got) + } +} + +func TestScanWorkspaceKeepsPartialResultsAndDeduplicatesAdvisories(t *testing.T) { + t.Parallel() + workspace := Workspace{Modules: []Module{ + {Dir: "/workspace/a", ModulePath: "example.com/a"}, + {Dir: "/workspace/b", ModulePath: "example.com/b"}, + {Dir: "/workspace/broken", ModulePath: "example.com/broken"}, + }} + execute := func(_ context.Context, module Module) ([]Vulnerability, error) { + if module.ModulePath == "example.com/broken" { + return nil, errors.New("packages unavailable") + } + return []Vulnerability{{AdvisoryID: "GO-2099-0001", Package: module.ModulePath + "/pkg", CallStack: []string{"entry", "sink"}}}, nil + } + + result := ScanWorkspace(context.Background(), workspace, ScanOptions{Concurrency: 2, ModuleTimeout: time.Second, Execute: execute}) + if len(result.Vulnerabilities) != 1 { + t.Fatalf("Vulnerabilities = %#v, want one deduplicated advisory", result.Vulnerabilities) + } + if got := len(result.Vulnerabilities[0].Occurrences); got != 2 { + t.Fatalf("Occurrences = %d, want both module occurrences", got) + } + if result.Modules[2].Status != ModuleFailed { + t.Fatalf("broken status = %q, want failed", result.Modules[2].Status) + } +} + +func TestScanWorkspaceTimesOutOneModuleWithoutDiscardingOthers(t *testing.T) { + t.Parallel() + workspace := Workspace{Modules: []Module{ + {Dir: "/workspace/fast", ModulePath: "example.com/fast"}, + {Dir: "/workspace/slow", ModulePath: "example.com/slow"}, + }} + execute := func(ctx context.Context, module Module) ([]Vulnerability, error) { + if module.ModulePath == "example.com/slow" { + <-ctx.Done() + return []Vulnerability{{AdvisoryID: "GO-2099-0003"}}, ctx.Err() + } + return []Vulnerability{{AdvisoryID: "GO-2099-0002"}}, nil + } + result := ScanWorkspace(context.Background(), workspace, ScanOptions{Concurrency: 2, ModuleTimeout: 10 * time.Millisecond, Execute: execute}) + if len(result.Vulnerabilities) != 2 { + t.Fatalf("Vulnerabilities = %#v, want fast and partial timed-out module results", result.Vulnerabilities) + } + if result.Modules[1].Status != ModuleTimedOut { + t.Fatalf("slow status = %q, want timed_out", result.Modules[1].Status) + } +} + +func TestDiscoverModulesUsesNearestNestedModule(t *testing.T) { + t.Parallel() + root := t.TempDir() + writeTestFile(t, root, "go.mod", "module example.com/root\n\ngo 1.24\n") + writeTestFile(t, root, "nested/go.mod", "module example.com/nested\n\ngo 1.24\n") + + workspace, err := DiscoverWorkspace(filepath.Join(root, "nested", "pkg")) + if err != nil { + t.Fatalf("DiscoverWorkspace() error = %v", err) + } + if len(workspace.Modules) != 1 || workspace.Modules[0].ModulePath != "example.com/nested" { + t.Fatalf("Modules = %#v, want nearest nested module", workspace.Modules) + } +} + +func writeTestFile(t *testing.T, root, rel, content string) { + t.Helper() + path := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/internal/codeguard/runner/support/findings_section.go b/internal/codeguard/runner/support/findings_section.go index a8009900..0fc22d19 100644 --- a/internal/codeguard/runner/support/findings_section.go +++ b/internal/codeguard/runner/support/findings_section.go @@ -77,6 +77,10 @@ func firstNonEmpty(values ...string) string { } func FinalizeSection(sc Context, id string, name string, findings []core.Finding) core.SectionResult { + return FinalizeSectionWithDiagnostics(sc, id, name, findings, nil) +} + +func FinalizeSectionWithDiagnostics(sc Context, id string, name string, findings []core.Finding, diagnostics []core.Diagnostic) core.SectionResult { section := core.SectionResult{ID: id, Name: name, Status: core.StatusPass} active := make([]core.Finding, 0, len(findings)) for _, finding := range findings { @@ -107,6 +111,20 @@ func FinalizeSection(sc Context, id string, name string, findings []core.Finding } } section.Findings = active + section.Diagnostics = diagnostics + for _, diagnostic := range diagnostics { + if !diagnostic.Operational { + continue + } + switch diagnostic.Level { + case "fail": + section.Status = core.StatusFail + case "warn": + if section.Status != core.StatusFail { + section.Status = core.StatusWarn + } + } + } if sc.Opts.OnSectionComplete != nil { sc.Opts.OnSectionComplete(section) } diff --git a/tests/checks/function_effect_evidence_test.go b/tests/checks/function_effect_evidence_test.go new file mode 100644 index 00000000..5a0720d1 --- /dev/null +++ b/tests/checks/function_effect_evidence_test.go @@ -0,0 +1,153 @@ +package checks_test + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestFunctionEffectsAllowLocalConstructionAndRepositoryHydration(t *testing.T) { + cases := []struct{ name, language, file, source string }{ + {"go protobuf", "go", "mapper.go", `package sample +func MapUser(row Row) *User { out := &User{}; out.SetName(row.Name); return out } +type Row struct { Name string }; type User struct{}; func (*User) SetName(string) {}`}, + {"go local payload", "go", "payload.go", `package sample +func BuildPayload(name string) map[string]any { payload := make(map[string]any); payload["name"] = name; return payload }`}, + {"go constructor builder", "go", "buffer.go", `package sample +type Buffer struct{}; func NewBuffer() *Buffer { return &Buffer{} }; func (*Buffer) Write(string) {} +func Render() *Buffer { out := NewBuffer(); out.Write("ok"); return out }`}, + {"go sql mapper", "go", "repository.go", `package sample +func FindUser(rows Rows) (*User, error) { out := &User{}; if err := rows.Scan(&out.Name); err != nil { return nil, err }; return out, nil } +type User struct { Name string }; type Rows interface { Scan(...any) error }`}, + {"cpp local dto", "cpp", "mapper.cpp", `User BuildUser(const Row& row) { User out{}; out.set_name(row.name()); return out; }`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, tc.file), tc.source) + report := runQualityPrecisionScan(t, qualityPrecisionConfigForLanguage(dir, tc.language)) + assertFindingRuleAbsent(t, report, "Code Quality", "function.hidden-mutation") + assertFindingRuleAbsent(t, report, "Code Quality", "function.command-query-mix") + }) + } +} + +func TestFunctionEffectsReportOwnedAndObservableMutationEvidence(t *testing.T) { + cases := []struct{ name, language, file, source, target, effect, origin string }{ + {"go argument alias", "go", "mutation.go", `package sample +type User struct { Name string } +func PrepareUser(user *User) *User { alias := user; alias.Name = "ready"; return user }`, "argument", "shared_state", "caller_owned"}, + {"go reassigned argument alias", "go", "reassignment.go", `package sample +type User struct { Name string } +func InspectUser(user *User) *User { var alias *User; alias = user; alias.Name = "seen"; return user }`, "argument", "shared_state", "caller_owned"}, + {"go receiver", "go", "receiver.go", `package sample +type Store struct { count int } +func (s *Store) Current() int { s.count++; return s.count }`, "receiver", "shared_state", "caller_owned"}, + {"cpp argument alias", "cpp", "mutation.cpp", `int Inspect(User& user) { User& alias = user; alias.score += 1; return alias.score; }`, "argument", "shared_state", "caller_owned"}, + {"go global", "go", "global.go", `package sample +var shared struct{ Count int } +func CurrentCount() int { shared.Count++; return shared.Count }`, "global", "shared_state", "shared"}, + {"go escaped local", "go", "escape.go", `package sample +type State struct{ Value int }; var sharedState *State +func CurrentState() *State { state := &State{}; sharedState = state; state.Value = 1; return state }`, "escaped", "shared_state", "shared"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, tc.file), tc.source) + report := runQualityPrecisionScan(t, qualityPrecisionConfigForLanguage(dir, tc.language)) + finding := findFinding(t, report, "Code Quality", "function.hidden-mutation") + for key, want := range map[string]string{"mutation_target": tc.target, "effect_kind": tc.effect, "origin": tc.origin} { + if got := finding.Metadata[key]; got != want { + t.Fatalf("%s = %q, want %q; metadata=%v", key, got, want, finding.Metadata) + } + } + if !strings.Contains(finding.Message, tc.target) { + t.Fatalf("message lacks target evidence: %q", finding.Message) + } + }) + } +} + +func TestCommandQueryMixUsesObservablePersistenceEvidence(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "repository.go"), `package sample +type Repo interface { SaveAudit(string) error; Find(string) (User, error) }; type User struct{} +func GetUser(repo Repo, id string) (User, error) { if err := repo.SaveAudit(id); err != nil { return User{}, err }; return repo.Find(id) }`) + report := runQualityPrecisionScan(t, qualityPrecisionConfig(dir)) + finding := findFinding(t, report, "Code Quality", "function.command-query-mix") + if finding.Metadata["effect_kind"] != "persistence" { + t.Fatalf("metadata = %v", finding.Metadata) + } +} + +func TestCommandQueryMixUsesEventEvidence(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "events.go"), `package sample +type Bus interface { Publish(string) error } +func GetStatus(bus Bus) (string, error) { if err := bus.Publish("read"); err != nil { return "", err }; return "ok", nil }`) + report := runQualityPrecisionScan(t, qualityPrecisionConfig(dir)) + finding := findFinding(t, report, "Code Quality", "function.command-query-mix") + if finding.Metadata["effect_kind"] != "event" { + t.Fatalf("metadata = %v", finding.Metadata) + } +} + +func TestOptionalReturnContractsAreDeliberate(t *testing.T) { + cases := []string{ + `package sample +func FindUser(id string) (*User, error) { if id == "" { return nil, nil }; return &User{}, nil }; type User struct{}`, + `package sample +func Lookup(id string) (User, bool, error) { if id == "" { return User{}, false, nil }; return User{}, true, nil }; type User struct{}`, + `package sample +func Tags(ok bool) []string { if !ok { return nil }; return []string{} }`, + `package sample +import "database/sql" +func Name(ok bool) sql.NullString { if !ok { return sql.NullString{} }; return sql.NullString{String: "Ada", Valid: true} }`, + } + for i, source := range cases { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "optional.go"), source) + report := runQualityPrecisionScan(t, qualityPrecisionConfig(dir)) + assertFindingRuleAbsent(t, report, "Code Quality", "function.inconsistent-return-contract") + _ = i + } + + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "optional.cpp"), `std::optional FindUser(bool found) { if (!found) return std::nullopt; return User{}; }`) + report := runQualityPrecisionScan(t, qualityPrecisionConfigForLanguage(dir, "cpp")) + assertFindingRuleAbsent(t, report, "Code Quality", "function.inconsistent-return-contract") +} + +func TestMessageChainsRequireIndependentCollaborators(t *testing.T) { + cases := []struct { + name, language, file, source string + }{ + {"go protobuf accessors", "go", "mapper.go", `package sample +func Region(msg *Envelope) string { return msg.GetUser().GetProfile().GetAddress().GetRegion() }`}, + {"cpp fluent builder", "cpp", "builder.cpp", `Response Build() { ResponseBuilder builder; return builder.WithCode(200).WithBody("ok").WithHeader("x", "y").Build(); }`}, + {"typescript json traversal", "typescript", "parser.ts", `export function region(payload: any) { return payload.json.user.profile.address.region; }`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, tc.file), tc.source) + report := runQualityPrecisionScan(t, qualityPrecisionConfigForLanguage(dir, tc.language)) + assertFindingRuleAbsent(t, report, "Code Quality", "smell.message-chain") + }) + } + + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "domain.go"), `package sample +func Country(order Order) string { return order.Customer().Account().Owner().Address().Country() }`) + report := runQualityPrecisionScan(t, qualityPrecisionConfig(dir)) + findFinding(t, report, "Code Quality", "smell.message-chain") +} + +func TestMessageChainAllowsRepeatedCallsOnLocalFluentValue(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "buffer.go"), `package sample +func Render() string { buf := NewBuffer(); return buf.Append("a").Append("b").Append("c").Finish() }`) + report := runQualityPrecisionScan(t, qualityPrecisionConfig(dir)) + assertFindingRuleAbsent(t, report, "Code Quality", "smell.message-chain") +} diff --git a/tests/checks/security_fixture_demotion_test.go b/tests/checks/security_fixture_demotion_test.go index 719d333f..0bc00d49 100644 --- a/tests/checks/security_fixture_demotion_test.go +++ b/tests/checks/security_fixture_demotion_test.go @@ -35,7 +35,7 @@ func fixtureDemotionReport(t *testing.T, dir string, demote *bool) codeguard.Rep return report } -func TestSecurityFixturePathsDemoteCredentialFindings(t *testing.T) { +func TestSecurityFixturePathsKeepProviderCredentialFindings(t *testing.T) { t.Parallel() cases := []struct { @@ -49,6 +49,8 @@ func TestSecurityFixturePathsDemoteCredentialFindings(t *testing.T) { {"ts test file", "web/app.test.ts"}, {"python test file", "scripts/util_test.py"}, {"ts spec file", "src/api.spec.ts"}, + {"json fixture", "fixtures/auth.json"}, + {"cpp fixture", "testdata/auth_test.cpp"}, } for _, tc := range cases { @@ -58,13 +60,12 @@ func TestSecurityFixturePathsDemoteCredentialFindings(t *testing.T) { writeFile(t, filepath.Join(dir, filepath.FromSlash(tc.path)), "key = \""+cred("AKIA", "1234567890ABCDEF")+"\"\n") report := fixtureDemotionReport(t, dir, nil) - // Demoted, never silent: the finding is still present at warn. - assertSectionStatus(t, report, "Security", "warn") - assertFindingLevel(t, report, "Security", "security.hardcoded-credential", "warn") - assertFindingConfidence(t, report, "Security", "security.hardcoded-credential", "low") + assertSectionStatus(t, report, "Security", "fail") + assertFindingLevel(t, report, "Security", "security.hardcoded-credential", "fail") + assertFindingConfidence(t, report, "Security", "security.hardcoded-credential", "high") finding := findFinding(t, report, "Security", "security.hardcoded-credential") - if !strings.HasSuffix(finding.Message, " (fixture path)") { - t.Fatalf("message missing fixture-path suffix: %q", finding.Message) + if finding.Metadata["classification"] != "confirmed" { + t.Fatalf("classification = %q, want confirmed", finding.Metadata["classification"]) } }) } @@ -110,12 +111,12 @@ func TestSecurityFixtureDemotionMarksNameBasedSecret(t *testing.T) { assertFindingLevel(t, report, "Security", "security.hardcoded-secret", "warn") assertFindingConfidence(t, report, "Security", "security.hardcoded-secret", "low") finding := findFinding(t, report, "Security", "security.hardcoded-secret") - if !strings.HasSuffix(finding.Message, " (fixture path)") { - t.Fatalf("message missing fixture-path suffix: %q", finding.Message) + if !strings.HasSuffix(finding.Message, " (ambiguous credential-shaped fixture)") { + t.Fatalf("message missing ambiguous fixture suffix: %q", finding.Message) } } -func TestSecurityFixtureDemotionDemotesHighEntropyString(t *testing.T) { +func TestSecurityFixtureKeepsHighEntropyStringStrict(t *testing.T) { t.Parallel() dir := t.TempDir() writeFile(t, filepath.Join(dir, "testdata", "blob.txt"), "blob = \"k7Jx9PqL2mNvB4wR8tZc3aYd5eHfUgQ1\"\n") @@ -124,12 +125,12 @@ func TestSecurityFixtureDemotionDemotesHighEntropyString(t *testing.T) { Enabled: boolPtr(true), Entropy: &codeguard.SecretsEntropyConfig{Enabled: boolPtr(true), Level: "fail"}, }, "go") - assertSectionStatus(t, report, "Security", "warn") - assertFindingLevel(t, report, "Security", "security.high-entropy-string", "warn") + assertSectionStatus(t, report, "Security", "fail") + assertFindingLevel(t, report, "Security", "security.high-entropy-string", "fail") assertFindingConfidence(t, report, "Security", "security.high-entropy-string", "low") finding := findFinding(t, report, "Security", "security.high-entropy-string") - if !strings.HasSuffix(finding.Message, " (fixture path)") { - t.Fatalf("message missing fixture-path suffix: %q", finding.Message) + if finding.Metadata["classification"] != "confirmed" { + t.Fatalf("classification = %q, want confirmed", finding.Metadata["classification"]) } } @@ -147,3 +148,51 @@ func TestSecurityFixtureDemotionKeepsPrivateKeyAtFail(t *testing.T) { t.Fatalf("private-key finding unexpectedly demoted: %q", finding.Message) } } + +func TestSecurityLikelySyntheticFixtureIsDiagnosticNotFinding(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "testdata", "auth.go"), "package testdata\nconst FakePassword = \"fixture-password-local\"\n") + + report := fixtureDemotionReport(t, dir, nil) + var section *codeguard.SectionResult + for i := range report.Sections { + if report.Sections[i].Name == "Security" { + section = &report.Sections[i] + break + } + } + if section == nil { + t.Fatal("Security section missing") + } + for _, finding := range section.Findings { + if finding.RuleID == "security.hardcoded-secret" { + t.Fatalf("synthetic fixture became baselinable finding: %#v", finding) + } + } + if len(section.Diagnostics) != 1 || section.Diagnostics[0].Kind != "likely_synthetic_fixture" { + t.Fatalf("diagnostics = %#v, want likely synthetic fixture", section.Diagnostics) + } +} + +func TestSecurityFixtureSymbolDoesNotSuppressRealSecretValue(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "testdata", "auth.go"), "package testdata\nconst FakePassword = \"actual-secret-value\"\n") + + report := fixtureDemotionReport(t, dir, nil) + assertFindingRulePresent(t, report, "Security", "security.hardcoded-secret") + finding := findFinding(t, report, "Security", "security.hardcoded-secret") + if finding.Metadata["classification"] == "likely_synthetic_fixture" { + t.Fatalf("fixture-shaped symbol suppressed a non-synthetic value: %#v", finding) + } +} + +func TestSecuritySyntheticTokensRequireValueComponents(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "testdata", "auth.go"), "package testdata\nconst FakePassword = \"latest-local-admin-secret\"\n") + + report := fixtureDemotionReport(t, dir, nil) + assertFindingRulePresent(t, report, "Security", "security.hardcoded-secret") +} diff --git a/tests/checks/security_secrets_test.go b/tests/checks/security_secrets_test.go index 44e8e9b9..b84b66c9 100644 --- a/tests/checks/security_secrets_test.go +++ b/tests/checks/security_secrets_test.go @@ -73,10 +73,10 @@ func TestSecuritySecretsAllowPathsSkipsFixtures(t *testing.T) { }, "go") assertSectionStatus(t, allowed, "Security", "pass") - // Without the allowlist the same fixture is still reported, but the - // default fixture-path demotion downgrades it from fail to warn. + // Without the allowlist the provider-shaped credential remains strict even + // in a fixture path; path evidence alone cannot demote it. blocked := secretsScanConfig(t, dir, nil, "go") - assertSectionStatus(t, blocked, "Security", "warn") + assertSectionStatus(t, blocked, "Security", "fail") assertFindingRulePresent(t, blocked, "Security", "security.hardcoded-credential") } diff --git a/tests/checks/security_test.go b/tests/checks/security_test.go index 410009c9..8597df47 100644 --- a/tests/checks/security_test.go +++ b/tests/checks/security_test.go @@ -138,6 +138,7 @@ func TestSecurityCheckWarnsWhenGovulncheckIsAutoButMissing(t *testing.T) { func TestSecurityCheckSurfacesStructuredGovulncheckFindings(t *testing.T) { dir := t.TempDir() + writeFile(t, filepath.Join(dir, "go.mod"), "module example.com/test\n\ngo 1.24\n") writeFile(t, filepath.Join(dir, "main.go"), "package main\nfunc main() {}\n") script := filepath.Join(dir, "fake-govulncheck.sh") writeExecutableFile(t, script, "#!/bin/sh\necho 'Vulnerability #1: GO-2024-0001'\necho ' Found in: example.com/module@v1.0.0'\necho ' Fixed in: example.com/module@v1.0.1'\necho ''\necho 'Vulnerability #2: GO-2024-0002'\necho ' Found in: example.com/other@v0.9.0'\nexit 1\n")