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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
268 changes: 268 additions & 0 deletions internal/codeguard/checks/quality/quality_effect_evidence.go
Original file line number Diff line number Diff line change
@@ -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 {

Check warning on line 46 in internal/codeguard/checks/quality/quality_effect_evidence.go

View workflow job for this annotation

GitHub Actions / codeguard

[quality.cyclomatic-complexity] function functionMutationEvidence has cyclomatic complexity 41; max is 18. Fix: Reduce branching in the function or refactor logic into smaller units.
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
}
Loading
Loading