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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ The additional detectors use the same persistent signal model. A threat-surface
| Secret | Findings from the embedded `git-pkgs/secrets` rule corpus; output contains `[redacted]` in place of the credential |
| Sensitive-Ignore | Effective `.gitignore` rules covering a bounded list of environment, credential, private-key, secret, state, and database paths, with the source pattern and line |
| REUSE | Per-file SPDX license declarations, REUSE.toml or DEP5 metadata, and the number of non-empty covered files with both license and copyright information |
| Repository-Ownership | CODEOWNERS rules, owner identities, semantic configuration digests, and incomplete coverage, plus content digests for maintainers and governance documents matched by Brief resource definitions |
| Security-Changelog | CVE and GHSA references, security-related entries, and list entries under security sections in changelog files recognized by Brief |
| Security-Documentation | Threat models, security audit or review files, and disclosure documents matched by Brief resource definitions outside bundled or derived paths |
| Artifact-Signing | GoReleaser artifact, container, binary, and native package signing configuration, including the mechanism, artifact type, and selected IDs |
Expand All @@ -206,7 +207,7 @@ The additional detectors use the same persistent signal model. A threat-surface

The root commit and missing history boundaries receive a full scan. Later commits reuse their first parent's signal state and update the paths changed by that commit. Cross-file detectors, including Agent Skills, dependency-update configuration, REUSE metadata, and sensitive ignore policy, receive a full commit tree when one of their inputs changes. Agent Skills rerun when a `SKILL.md` or a file inside a known skill package changes. Sensitive ignore policy reruns only when a project `.gitignore` changes.

Parser results for changed files are cached by Git blob OID and path for the whole run. Repository files that cannot contain an attestation, certificate, license, SARIF log, SBOM, fuzzer, policy, changelog entry, markup with remote subresources, SAST configuration, or shell program need only a 1 KB prefix for binary classification. Attestation parsing is limited to 8 MB per file. Markup scanning reads at most 8 MB per file. Files without a shell extension are read up to 1 MB when the prefix contains a shell shebang. Security-document paths reuse that prefix pass and need no content read. SARIF candidates use streaming byte, tool, and distinct-value limits, and bundled, derived, test, and example paths are skipped for SARIF detection. Commit reading can run concurrently, while signal-state updates remain ordered. `github.com/git-pkgs/languages` keeps a language inventory so Brief can rerun language-gated detection only when needed. `github.com/git-pkgs/roles` removes bundled and derived paths from attestation, fuzzing, license, security-document, shell, and subresource-integrity detection, and removes test, fixture, example, cache, and build-output paths from certificate detection. Test paths remain eligible for fuzz detection because fuzz targets commonly use them.
Parser results for changed files are cached by Git blob OID and path for the whole run. Repository files that cannot contain an attestation, certificate, license, ownership document, SARIF log, SBOM, fuzzer, policy, changelog entry, markup with remote subresources, SAST configuration, or shell program need only a 1 KB prefix for binary classification. Attestation parsing is limited to 8 MB per file. CODEOWNERS parsing reads at most 3 MB and reports incomplete coverage for larger files; maintainer and governance documents use streaming content digests. Markup scanning reads at most 8 MB per file. Files without a shell extension are read up to 1 MB when the prefix contains a shell shebang. Security-document paths reuse that prefix pass and need no content read. SARIF candidates use streaming byte, tool, and distinct-value limits, and bundled, derived, test, and example paths are skipped for SARIF detection. Commit reading can run concurrently, while signal-state updates remain ordered. `github.com/git-pkgs/languages` keeps a language inventory so Brief can rerun language-gated detection only when needed. `github.com/git-pkgs/roles` removes bundled and derived paths from attestation, fuzzing, license, ownership, security-document, shell, and subresource-integrity detection, and removes test, fixture, example, cache, and build-output paths from certificate detection. Test paths remain eligible for fuzz detection because fuzz targets commonly use them.

Brief reads commit trees through `history.TreeFS` and reruns only when Brief's changed-file filter identifies an input to one of the emitted tool signals. Security-tool signals require evidence of configured use, such as a dedicated configuration file, declared dependency, action, or content match. A language manifest that only makes a scanner applicable does not emit a `Security-Tool` signal. Package files are parsed when their paths change, and the parse result supplies dependency constraints, pins, Actions references, container images, lockfile resolution, integrity records, and manifest-declared license values or file references. Results are cached by blob OID and path. Shell parsing follows downloads into checksum checks and later execution, while package commands use the command definitions loaded from `github.com/git-pkgs/managers`. The same parser handles shell files, Dockerfile `RUN` instructions, and Actions `run` steps. Package-manager detection uses `managers.AffectedDirectories` to rescan only the affected projects. Secret scanning updates changed paths and caches results by blob OID and path because some rules depend on the filename. REUSE metadata changes trigger a full REUSE scan; ordinary changes update the affected file state and coverage count.

Expand Down
1 change: 1 addition & 0 deletions detector.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const (
checkDependencyUpdateTool = "Dependency-Update-Tool"
checkFuzzing = "Fuzzing"
checkLicense = "License"
checkRepositoryOwnership = "Repository-Ownership"
checkSARIF = "SARIF"
checkSAST = "SAST"
checkSBOM = "SBOM"
Expand Down
195 changes: 195 additions & 0 deletions ownership.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package main

import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"io/fs"
"regexp"
"sort"
"strings"

"github.com/git-pkgs/brief/kb"
)

const (
ownershipKindCodeowners = "codeowners"
ownershipKindOwner = "owner"
ownershipKindMaintainers = "maintainers"
ownershipKindGovernance = "governance"
ownershipKindCoverage = "coverage"
ownershipTruncatedName = "truncated"
ownershipSummaryName = "summary"
codeownersContentBytes = 3 << 20
)

var codeownerEmailPattern = regexp.MustCompile("^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,63}$")

type ownershipRules struct {
codeowners *kb.ResourceInfo
maintainers *kb.ResourceInfo
governance *kb.ResourceInfo
}

func newOwnershipRules(knowledge *kb.KnowledgeBase) *ownershipRules {
return &ownershipRules{
codeowners: knowledge.Resource("community", ownershipKindCodeowners),
maintainers: knowledge.Resource("community", ownershipKindMaintainers),
governance: knowledge.Resource("community", ownershipKindGovernance),
}
}

func (rules *ownershipRules) kind(filePath string) string {
if rules == nil {
return ""
}
var kind string
switch {
case matchesResource(rules.codeowners, filePath):
kind = ownershipKindCodeowners
case matchesResource(rules.maintainers, filePath):
kind = ownershipKindMaintainers
case matchesResource(rules.governance, filePath):
kind = ownershipKindGovernance
}
if kind == "" || !repositoryProjectEvidencePath(filePath) {
return ""
}
return kind
}

func codeownersSignals(filePath string, data []byte, size int64) []Signal {
owners := make(map[string]bool)
var statements []string
ruleCount := 0
gitlabSyntax := strings.HasPrefix(strings.ToLower(filePath), ".gitlab/")
for _, line := range bytes.Split(data, []byte{'\n'}) {
statement := codeownersStatement(string(line), gitlabSyntax)
if statement == "" {
continue
}
fields := strings.Fields(statement)
hasOwner := false
for _, field := range fields[1:] {
if codeownerIdentity(field) {
owners[field] = true
hasOwner = true
}
}
section := codeownersSection(statement)
if !section && !gitlabSyntax && !hasOwner {
continue
}
statements = append(statements, strings.Join(fields, " "))
if !section {
ruleCount++
}
}
summary := fmt.Sprintf(
"rules=%d; owners=%d; config=%s",
ruleCount,
len(owners),
contentDigest([]byte(strings.Join(statements, "\n"))),
)
signals := []Signal{{
Check: checkRepositoryOwnership,
Kind: ownershipKindCodeowners,
Name: ownershipSummaryName,
Value: summary,
Path: filePath,
}}
ownerNames := make([]string, 0, len(owners))
for owner := range owners {
ownerNames = append(ownerNames, owner)
}
sort.Strings(ownerNames)
for _, owner := range ownerNames {
signals = append(signals, Signal{
Check: checkRepositoryOwnership,
Kind: ownershipKindOwner,
Name: owner,
Path: filePath,
})
}
if size > codeownersContentBytes {
signals = append(signals, Signal{
Check: checkRepositoryOwnership,
Kind: ownershipKindCoverage,
Name: ownershipTruncatedName,
Value: fmt.Sprintf("bytes=%d; limit=%d", size, codeownersContentBytes),
Path: filePath,
})
}
return signals
}

func repositoryOwnershipSignals(
tree fs.FS,
filePath string,
size int64,
rules *ownershipRules,
) ([]Signal, error) {
kind := rules.kind(filePath)
if kind == "" {
return nil, nil
}
if kind == ownershipKindCodeowners {
data, err := readRepositoryFile(tree, filePath, codeownersContentBytes)
if err != nil {
return nil, err
}
return codeownersSignals(filePath, data, size), nil
}
digest, err := ownershipDocumentDigest(tree, filePath)
if err != nil {
return nil, err
}
return []Signal{{
Check: checkRepositoryOwnership,
Kind: kind,
Value: digest,
Path: filePath,
}}, nil
}

func ownershipDocumentDigest(tree fs.FS, filePath string) (string, error) {
hasher := sha256.New()
err := scanRepositoryFileError(tree, filePath, func(reader io.Reader) error {
_, err := io.Copy(hasher, reader)
return err
})
digest := hasher.Sum(nil)
return "sha256:" + hex.EncodeToString(digest[:6]), err
}

func codeownersStatement(line string, gitlabSyntax bool) string {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "#") || strings.HasPrefix(line, `\#`) {
return ""
}
if gitlabSyntax {
return line
}
fields := strings.Fields(line)
for index, field := range fields {
if strings.HasPrefix(field, "#") {
fields = fields[:index]
break
}
}
return strings.Join(fields, " ")
}

func codeownerIdentity(value string) bool {
return (len(value) > 1 && strings.HasPrefix(value, "@")) || codeownerEmailPattern.MatchString(value)
}

func codeownersSection(statement string) bool {
fields := strings.Fields(statement)
if len(fields) == 0 {
return false
}
return strings.HasPrefix(strings.TrimPrefix(fields[0], "^"), "[")
}
Loading
Loading