refactor(ax): round 7 AX sweep — replace banned imports with core primitives, add security tests
Replace fmt/strings/path/filepath with core.Sprintf, core.Lower, core.Upper, core.Split, core.SplitN, core.Trim, core.Join, core.Contains, core.NewBuilder, core.Path across ai/, cmd/metrics/, and cmd/security/. Add Bad/Ugly test coverage for the untested security package (AlertSummary, filterBySeverity, buildTargetRepo, buildJobIssueBody). os/exec kept for gh CLI invocations (no core equivalent in dep graph). encoding/json kept for MarshalIndent (no core.JSONMarshalIndent yet). Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
45e76101bf
commit
aff8ff4b3b
11 changed files with 360 additions and 91 deletions
|
|
@ -5,11 +5,11 @@ import (
|
|||
"cmp"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
coreio "dappco.re/go/core/io"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
|
@ -36,14 +36,14 @@ func metricsDir() (string, error) {
|
|||
if err != nil {
|
||||
return "", coreerr.E("ai.metricsDir", "get home directory", err)
|
||||
}
|
||||
return filepath.Join(home, ".core", "ai", "metrics"), nil
|
||||
return core.Path(home, ".core", "ai", "metrics"), nil
|
||||
}
|
||||
|
||||
// metricsFilePath joins the daily JSONL filename onto dir.
|
||||
//
|
||||
// ai.metricsFilePath("/home/user/.core/ai/metrics", time.Now()) // → "/home/user/.core/ai/metrics/2026-03-31.jsonl"
|
||||
func metricsFilePath(dir string, t time.Time) string {
|
||||
return filepath.Join(dir, t.Format("2006-01-02")+".jsonl")
|
||||
return core.Path(dir, t.Format("2006-01-02")+".jsonl")
|
||||
}
|
||||
|
||||
// Record appends an event to ~/.core/ai/metrics/YYYY-MM-DD.jsonl.
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
package ai
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
coreio "dappco.re/go/core/io"
|
||||
)
|
||||
|
||||
|
|
@ -17,7 +16,7 @@ func setupBenchMetricsDir(b *testing.B) string {
|
|||
b.Helper()
|
||||
origHome := os.Getenv("HOME")
|
||||
tmpHome := b.TempDir()
|
||||
metricsPath := filepath.Join(tmpHome, ".core", "ai", "metrics")
|
||||
metricsPath := core.Path(tmpHome, ".core", "ai", "metrics")
|
||||
if err := coreio.Local.EnsureDir(metricsPath); err != nil {
|
||||
b.Fatalf("Failed to create metrics dir: %v", err)
|
||||
}
|
||||
|
|
@ -36,10 +35,10 @@ func seedEvents(b *testing.B, count int) {
|
|||
now := time.Now()
|
||||
for eventIndex := range count {
|
||||
event := Event{
|
||||
Type: fmt.Sprintf("type-%d", eventIndex%10),
|
||||
Type: core.Sprintf("type-%d", eventIndex%10),
|
||||
Timestamp: now.Add(-time.Duration(eventIndex) * time.Millisecond),
|
||||
AgentID: fmt.Sprintf("agent-%d", eventIndex%5),
|
||||
Repo: fmt.Sprintf("repo-%d", eventIndex%3),
|
||||
AgentID: core.Sprintf("agent-%d", eventIndex%5),
|
||||
Repo: core.Sprintf("repo-%d", eventIndex%3),
|
||||
Data: map[string]any{"index": eventIndex, "tool": "bench_tool"},
|
||||
}
|
||||
if err := Record(event); err != nil {
|
||||
|
|
@ -161,7 +160,7 @@ func BenchmarkMetricsRecordAndQuery(b *testing.B) {
|
|||
// Write 10K events
|
||||
for eventIndex := range 10_000 {
|
||||
event := Event{
|
||||
Type: fmt.Sprintf("type-%d", eventIndex%10),
|
||||
Type: core.Sprintf("type-%d", eventIndex%10),
|
||||
Timestamp: now,
|
||||
AgentID: "bench",
|
||||
Repo: "bench-repo",
|
||||
|
|
@ -192,10 +191,10 @@ func TestMetricsBench_RecordAndRead_10K_Good(t *testing.T) {
|
|||
|
||||
for eventIndex := range eventCount {
|
||||
event := Event{
|
||||
Type: fmt.Sprintf("type-%d", eventIndex%10),
|
||||
Type: core.Sprintf("type-%d", eventIndex%10),
|
||||
Timestamp: now.Add(-time.Duration(eventIndex) * time.Millisecond),
|
||||
AgentID: fmt.Sprintf("agent-%d", eventIndex%5),
|
||||
Repo: fmt.Sprintf("repo-%d", eventIndex%3),
|
||||
AgentID: core.Sprintf("agent-%d", eventIndex%5),
|
||||
Repo: core.Sprintf("repo-%d", eventIndex%3),
|
||||
Data: map[string]any{"index": eventIndex},
|
||||
}
|
||||
if err := Record(event); err != nil {
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ package ai
|
|||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
coreio "dappco.re/go/core/io"
|
||||
)
|
||||
|
||||
|
|
@ -14,7 +14,7 @@ func withTempHome(t *testing.T) {
|
|||
t.Helper()
|
||||
origHome := os.Getenv("HOME")
|
||||
tmpHome := t.TempDir()
|
||||
metricsPath := filepath.Join(tmpHome, ".core", "ai", "metrics")
|
||||
metricsPath := core.Path(tmpHome, ".core", "ai", "metrics")
|
||||
if err := coreio.Local.EnsureDir(metricsPath); err != nil {
|
||||
t.Fatalf("create metrics dir: %v", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,10 @@ package metrics
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
"dappco.re/go/core/ai/ai"
|
||||
"dappco.re/go/core/i18n"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
|
|
@ -116,19 +115,19 @@ func runMetrics() error {
|
|||
// parseDuration("30m") // → 30 * time.Minute
|
||||
func parseDuration(durationText string) (time.Duration, error) {
|
||||
if len(durationText) < 2 {
|
||||
return 0, coreerr.E("metrics.parseDuration", fmt.Sprintf("invalid duration: %s", durationText), nil)
|
||||
return 0, coreerr.E("metrics.parseDuration", core.Sprintf("invalid duration: %s", durationText), nil)
|
||||
}
|
||||
|
||||
unitSuffix := durationText[len(durationText)-1]
|
||||
countText := strings.TrimSpace(durationText[:len(durationText)-1])
|
||||
countText := core.Trim(durationText[:len(durationText)-1])
|
||||
|
||||
quantity, err := strconv.Atoi(countText)
|
||||
if err != nil {
|
||||
return 0, coreerr.E("metrics.parseDuration", fmt.Sprintf("invalid duration: %s", durationText), nil)
|
||||
return 0, coreerr.E("metrics.parseDuration", core.Sprintf("invalid duration: %s", durationText), nil)
|
||||
}
|
||||
|
||||
if quantity <= 0 {
|
||||
return 0, coreerr.E("metrics.parseDuration", fmt.Sprintf("duration must be positive: %s", durationText), nil)
|
||||
return 0, coreerr.E("metrics.parseDuration", core.Sprintf("duration must be positive: %s", durationText), nil)
|
||||
}
|
||||
|
||||
switch unitSuffix {
|
||||
|
|
@ -139,6 +138,6 @@ func parseDuration(durationText string) (time.Duration, error) {
|
|||
case 'm':
|
||||
return time.Duration(quantity) * time.Minute, nil
|
||||
default:
|
||||
return 0, coreerr.E("metrics.parseDuration", fmt.Sprintf("unknown unit %c in duration: %s", unitSuffix, durationText), nil)
|
||||
return 0, coreerr.E("metrics.parseDuration", core.Sprintf("unknown unit %c in duration: %s", unitSuffix, durationText), nil)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ package security
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
"dappco.re/go/core/i18n"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
)
|
||||
|
|
@ -71,7 +71,7 @@ func runAlerts() error {
|
|||
summary := &AlertSummary{}
|
||||
|
||||
for _, repo := range repoList {
|
||||
repoFullName := fmt.Sprintf("%s/%s", registry.Org, repo.Name)
|
||||
repoFullName := core.Sprintf("%s/%s", registry.Org, repo.Name)
|
||||
|
||||
dependabotAlerts, err := fetchDependabotAlerts(repoFullName)
|
||||
if err == nil {
|
||||
|
|
@ -107,7 +107,7 @@ func runAlerts() error {
|
|||
continue
|
||||
}
|
||||
summary.Add(severity)
|
||||
location := fmt.Sprintf("%s:%d", alert.MostRecentInstance.Location.Path, alert.MostRecentInstance.Location.StartLine)
|
||||
location := core.Sprintf("%s:%d", alert.MostRecentInstance.Location.Path, alert.MostRecentInstance.Location.StartLine)
|
||||
allAlerts = append(allAlerts, AlertOutput{
|
||||
Repo: repo.Name,
|
||||
Severity: severity,
|
||||
|
|
@ -132,7 +132,7 @@ func runAlerts() error {
|
|||
allAlerts = append(allAlerts, AlertOutput{
|
||||
Repo: repo.Name,
|
||||
Severity: "high",
|
||||
ID: fmt.Sprintf("secret-%d", alert.Number),
|
||||
ID: core.Sprintf("secret-%d", alert.Number),
|
||||
Type: "secret-scanning",
|
||||
Message: alert.SecretType,
|
||||
})
|
||||
|
|
@ -165,12 +165,12 @@ func runAlerts() error {
|
|||
location = alert.Location
|
||||
}
|
||||
if alert.Version != "" {
|
||||
location = fmt.Sprintf("%s %s", location, cli.DimStyle.Render(alert.Version))
|
||||
location = core.Sprintf("%s %s", location, cli.DimStyle.Render(alert.Version))
|
||||
}
|
||||
|
||||
cli.Print("%-20s %s %-16s %-40s %s\n",
|
||||
cli.ValueStyle.Render(alert.Repo),
|
||||
severityRenderer.Render(fmt.Sprintf("%-8s", alert.Severity)),
|
||||
severityRenderer.Render(core.Sprintf("%-8s", alert.Severity)),
|
||||
alert.ID,
|
||||
location,
|
||||
cli.DimStyle.Render(alert.Type),
|
||||
|
|
@ -227,7 +227,7 @@ func runAlertsForTarget(target string) error {
|
|||
continue
|
||||
}
|
||||
summary.Add(severity)
|
||||
location := fmt.Sprintf("%s:%d", alert.MostRecentInstance.Location.Path, alert.MostRecentInstance.Location.StartLine)
|
||||
location := core.Sprintf("%s:%d", alert.MostRecentInstance.Location.Path, alert.MostRecentInstance.Location.StartLine)
|
||||
allAlerts = append(allAlerts, AlertOutput{
|
||||
Repo: repo.Name,
|
||||
Severity: severity,
|
||||
|
|
@ -252,7 +252,7 @@ func runAlertsForTarget(target string) error {
|
|||
allAlerts = append(allAlerts, AlertOutput{
|
||||
Repo: repo.Name,
|
||||
Severity: "high",
|
||||
ID: fmt.Sprintf("secret-%d", alert.Number),
|
||||
ID: core.Sprintf("secret-%d", alert.Number),
|
||||
Type: "secret-scanning",
|
||||
Message: alert.SecretType,
|
||||
})
|
||||
|
|
@ -283,11 +283,11 @@ func runAlertsForTarget(target string) error {
|
|||
location = alert.Location
|
||||
}
|
||||
if alert.Version != "" {
|
||||
location = fmt.Sprintf("%s %s", location, cli.DimStyle.Render(alert.Version))
|
||||
location = core.Sprintf("%s %s", location, cli.DimStyle.Render(alert.Version))
|
||||
}
|
||||
cli.Print("%-20s %s %-16s %-40s %s\n",
|
||||
cli.ValueStyle.Render(alert.Repo),
|
||||
severityRenderer.Render(fmt.Sprintf("%-8s", alert.Severity)),
|
||||
severityRenderer.Render(core.Sprintf("%-8s", alert.Severity)),
|
||||
alert.ID,
|
||||
location,
|
||||
cli.DimStyle.Render(alert.Type),
|
||||
|
|
@ -302,15 +302,15 @@ func runAlertsForTarget(target string) error {
|
|||
//
|
||||
// alerts, _ := fetchDependabotAlerts("host-uk/core-php")
|
||||
func fetchDependabotAlerts(repoFullName string) ([]DependabotAlert, error) {
|
||||
endpoint := fmt.Sprintf("repos/%s/dependabot/alerts?state=open", repoFullName)
|
||||
endpoint := core.Sprintf("repos/%s/dependabot/alerts?state=open", repoFullName)
|
||||
output, err := runGHAPI(endpoint)
|
||||
if err != nil {
|
||||
return nil, cli.Wrap(err, fmt.Sprintf("fetch dependabot alerts for %s", repoFullName))
|
||||
return nil, cli.Wrap(err, core.Sprintf("fetch dependabot alerts for %s", repoFullName))
|
||||
}
|
||||
|
||||
var alerts []DependabotAlert
|
||||
if err := json.Unmarshal(output, &alerts); err != nil {
|
||||
return nil, cli.Wrap(err, fmt.Sprintf("parse dependabot alerts for %s", repoFullName))
|
||||
return nil, cli.Wrap(err, core.Sprintf("parse dependabot alerts for %s", repoFullName))
|
||||
}
|
||||
return alerts, nil
|
||||
}
|
||||
|
|
@ -319,15 +319,15 @@ func fetchDependabotAlerts(repoFullName string) ([]DependabotAlert, error) {
|
|||
//
|
||||
// alerts, _ := fetchCodeScanningAlerts("host-uk/core-php")
|
||||
func fetchCodeScanningAlerts(repoFullName string) ([]CodeScanningAlert, error) {
|
||||
endpoint := fmt.Sprintf("repos/%s/code-scanning/alerts?state=open", repoFullName)
|
||||
endpoint := core.Sprintf("repos/%s/code-scanning/alerts?state=open", repoFullName)
|
||||
output, err := runGHAPI(endpoint)
|
||||
if err != nil {
|
||||
return nil, cli.Wrap(err, fmt.Sprintf("fetch code-scanning alerts for %s", repoFullName))
|
||||
return nil, cli.Wrap(err, core.Sprintf("fetch code-scanning alerts for %s", repoFullName))
|
||||
}
|
||||
|
||||
var alerts []CodeScanningAlert
|
||||
if err := json.Unmarshal(output, &alerts); err != nil {
|
||||
return nil, cli.Wrap(err, fmt.Sprintf("parse code-scanning alerts for %s", repoFullName))
|
||||
return nil, cli.Wrap(err, core.Sprintf("parse code-scanning alerts for %s", repoFullName))
|
||||
}
|
||||
return alerts, nil
|
||||
}
|
||||
|
|
@ -336,15 +336,15 @@ func fetchCodeScanningAlerts(repoFullName string) ([]CodeScanningAlert, error) {
|
|||
//
|
||||
// alerts, _ := fetchSecretScanningAlerts("host-uk/core-php")
|
||||
func fetchSecretScanningAlerts(repoFullName string) ([]SecretScanningAlert, error) {
|
||||
endpoint := fmt.Sprintf("repos/%s/secret-scanning/alerts?state=open", repoFullName)
|
||||
endpoint := core.Sprintf("repos/%s/secret-scanning/alerts?state=open", repoFullName)
|
||||
output, err := runGHAPI(endpoint)
|
||||
if err != nil {
|
||||
return nil, cli.Wrap(err, fmt.Sprintf("fetch secret-scanning alerts for %s", repoFullName))
|
||||
return nil, cli.Wrap(err, core.Sprintf("fetch secret-scanning alerts for %s", repoFullName))
|
||||
}
|
||||
|
||||
var alerts []SecretScanningAlert
|
||||
if err := json.Unmarshal(output, &alerts); err != nil {
|
||||
return nil, cli.Wrap(err, fmt.Sprintf("parse secret-scanning alerts for %s", repoFullName))
|
||||
return nil, cli.Wrap(err, core.Sprintf("parse secret-scanning alerts for %s", repoFullName))
|
||||
}
|
||||
return alerts, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ package security
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
"dappco.re/go/core/i18n"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
)
|
||||
|
|
@ -72,7 +72,7 @@ func runDeps() error {
|
|||
summary := &AlertSummary{}
|
||||
|
||||
for _, repo := range repoList {
|
||||
repoFullName := fmt.Sprintf("%s/%s", registry.Org, repo.Name)
|
||||
repoFullName := core.Sprintf("%s/%s", registry.Org, repo.Name)
|
||||
|
||||
dependabotAlerts, err := fetchDependabotAlerts(repoFullName)
|
||||
if err != nil {
|
||||
|
|
@ -129,12 +129,12 @@ func runDeps() error {
|
|||
|
||||
upgrade := alert.Vulnerable
|
||||
if alert.PatchedVersion != "" {
|
||||
upgrade = fmt.Sprintf("%s -> %s", alert.Vulnerable, cli.SuccessStyle.Render(alert.PatchedVersion))
|
||||
upgrade = core.Sprintf("%s -> %s", alert.Vulnerable, cli.SuccessStyle.Render(alert.PatchedVersion))
|
||||
}
|
||||
|
||||
cli.Print("%-16s %s %-16s %-30s %s\n",
|
||||
cli.ValueStyle.Render(alert.Repo),
|
||||
severityRenderer.Render(fmt.Sprintf("%-8s", alert.Severity)),
|
||||
severityRenderer.Render(core.Sprintf("%-8s", alert.Severity)),
|
||||
alert.CVE,
|
||||
alert.Package,
|
||||
upgrade,
|
||||
|
|
@ -201,11 +201,11 @@ func runDepsForTarget(target string) error {
|
|||
severityRenderer := severityStyle(alert.Severity)
|
||||
upgrade := alert.Vulnerable
|
||||
if alert.PatchedVersion != "" {
|
||||
upgrade = fmt.Sprintf("%s -> %s", alert.Vulnerable, cli.SuccessStyle.Render(alert.PatchedVersion))
|
||||
upgrade = core.Sprintf("%s -> %s", alert.Vulnerable, cli.SuccessStyle.Render(alert.PatchedVersion))
|
||||
}
|
||||
cli.Print("%-16s %s %-16s %-30s %s\n",
|
||||
cli.ValueStyle.Render(alert.Repo),
|
||||
severityRenderer.Render(fmt.Sprintf("%-8s", alert.Severity)),
|
||||
severityRenderer.Render(core.Sprintf("%-8s", alert.Severity)),
|
||||
alert.CVE,
|
||||
alert.Package,
|
||||
upgrade,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
package security
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
"dappco.re/go/core/ai/ai"
|
||||
"dappco.re/go/core/i18n"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
|
|
@ -77,7 +76,7 @@ func runJobs() error {
|
|||
//
|
||||
// createJobForTarget("wailsapp/wails")
|
||||
func createJobForTarget(target string) error {
|
||||
targetParts := strings.SplitN(target, "/", 2)
|
||||
targetParts := core.SplitN(target, "/", 2)
|
||||
if len(targetParts) != 2 || targetParts[0] == "" || targetParts[1] == "" {
|
||||
return coreerr.E("security.createJobForTarget", "invalid target format: use owner/repo", nil)
|
||||
}
|
||||
|
|
@ -101,8 +100,8 @@ func createJobForTarget(target string) error {
|
|||
severity = "medium"
|
||||
}
|
||||
summary.Add(severity)
|
||||
findings = append(findings, fmt.Sprintf("- [%s] %s: %s (%s:%d)",
|
||||
strings.ToUpper(severity), alert.Tool.Name, alert.Rule.Description,
|
||||
findings = append(findings, core.Sprintf("- [%s] %s: %s (%s:%d)",
|
||||
core.Upper(severity), alert.Tool.Name, alert.Rule.Description,
|
||||
alert.MostRecentInstance.Location.Path, alert.MostRecentInstance.Location.StartLine))
|
||||
}
|
||||
}
|
||||
|
|
@ -118,8 +117,8 @@ func createJobForTarget(target string) error {
|
|||
continue
|
||||
}
|
||||
summary.Add(alert.Advisory.Severity)
|
||||
findings = append(findings, fmt.Sprintf("- [%s] %s: %s (%s)",
|
||||
strings.ToUpper(alert.Advisory.Severity), alert.Dependency.Package.Name,
|
||||
findings = append(findings, core.Sprintf("- [%s] %s: %s (%s)",
|
||||
core.Upper(alert.Advisory.Severity), alert.Dependency.Package.Name,
|
||||
alert.Advisory.Summary, alert.Advisory.CVEID))
|
||||
}
|
||||
}
|
||||
|
|
@ -135,12 +134,12 @@ func createJobForTarget(target string) error {
|
|||
continue
|
||||
}
|
||||
summary.Add("high")
|
||||
findings = append(findings, fmt.Sprintf("- [HIGH] Secret: %s (#%d)", alert.SecretType, alert.Number))
|
||||
findings = append(findings, core.Sprintf("- [HIGH] Secret: %s (#%d)", alert.SecretType, alert.Number))
|
||||
}
|
||||
}
|
||||
|
||||
if fetchErrors == 3 {
|
||||
return coreerr.E("security.createJobForTarget", fmt.Sprintf("failed to fetch any alerts for %s", target), nil)
|
||||
return coreerr.E("security.createJobForTarget", core.Sprintf("failed to fetch any alerts for %s", target), nil)
|
||||
}
|
||||
|
||||
if summary.Total == 0 {
|
||||
|
|
@ -148,13 +147,13 @@ func createJobForTarget(target string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
title := fmt.Sprintf("Security scan: %s", target)
|
||||
title := core.Sprintf("Security scan: %s", target)
|
||||
issueBody := buildJobIssueBody(target, summary, findings)
|
||||
|
||||
for copyIndex := range jobsCopies {
|
||||
issueTitle := title
|
||||
if jobsCopies > 1 {
|
||||
issueTitle = fmt.Sprintf("%s (#%d)", title, copyIndex+1)
|
||||
issueTitle = core.Sprintf("%s (#%d)", title, copyIndex+1)
|
||||
}
|
||||
|
||||
if jobsDryRun {
|
||||
|
|
@ -175,10 +174,10 @@ func createJobForTarget(target string) error {
|
|||
|
||||
output, err := issueCommand.CombinedOutput()
|
||||
if err != nil {
|
||||
return cli.Wrap(err, fmt.Sprintf("create issue for %s: %s", target, string(output)))
|
||||
return cli.Wrap(err, core.Sprintf("create issue for %s: %s", target, string(output)))
|
||||
}
|
||||
|
||||
issueURL := strings.TrimSpace(string(output))
|
||||
issueURL := core.Trim(string(output))
|
||||
cli.Print("%s %s: %s\n", cli.SuccessStyle.Render(">>"), issueTitle, issueURL)
|
||||
|
||||
_ = ai.Record(ai.Event{
|
||||
|
|
@ -202,17 +201,17 @@ func createJobForTarget(target string) error {
|
|||
//
|
||||
// body := buildJobIssueBody("wailsapp/wails", summary, findings)
|
||||
func buildJobIssueBody(target string, summary *AlertSummary, findings []string) string {
|
||||
var issueBodyBuilder strings.Builder
|
||||
issueBodyBuilder := core.NewBuilder()
|
||||
|
||||
fmt.Fprintf(&issueBodyBuilder, "## Security Scan: %s\n\n", target)
|
||||
fmt.Fprintf(&issueBodyBuilder, "**Summary:** %s\n\n", summary.String())
|
||||
issueBodyBuilder.WriteString(core.Sprintf("## Security Scan: %s\n\n", target))
|
||||
issueBodyBuilder.WriteString(core.Sprintf("**Summary:** %s\n\n", summary.String()))
|
||||
|
||||
issueBodyBuilder.WriteString("### Findings\n\n")
|
||||
if len(findings) > 50 {
|
||||
for _, finding := range findings[:50] {
|
||||
issueBodyBuilder.WriteString(finding + "\n")
|
||||
}
|
||||
fmt.Fprintf(&issueBodyBuilder, "\n... and %d more\n", len(findings)-50)
|
||||
issueBodyBuilder.WriteString(core.Sprintf("\n... and %d more\n", len(findings)-50))
|
||||
} else {
|
||||
for _, finding := range findings {
|
||||
issueBodyBuilder.WriteString(finding + "\n")
|
||||
|
|
@ -227,7 +226,7 @@ func buildJobIssueBody(target string, summary *AlertSummary, findings []string)
|
|||
|
||||
issueBodyBuilder.WriteString("\n### Instructions\n\n")
|
||||
issueBodyBuilder.WriteString("1. Claim this issue by assigning yourself\n")
|
||||
fmt.Fprintf(&issueBodyBuilder, "2. Run `core security alerts --target %s` for the latest findings\n", target)
|
||||
issueBodyBuilder.WriteString(core.Sprintf("2. Run `core security alerts --target %s` for the latest findings\n", target))
|
||||
issueBodyBuilder.WriteString("3. Work through the checklist above\n")
|
||||
issueBodyBuilder.WriteString("4. Close this issue when all findings are addressed\n")
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ package security
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
"dappco.re/go/core/ai/ai"
|
||||
"dappco.re/go/core/i18n"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
|
|
@ -78,7 +78,7 @@ func runScan() error {
|
|||
summary := &AlertSummary{}
|
||||
|
||||
for _, repo := range repoList {
|
||||
repoFullName := fmt.Sprintf("%s/%s", registry.Org, repo.Name)
|
||||
repoFullName := core.Sprintf("%s/%s", registry.Org, repo.Name)
|
||||
|
||||
codeScanningAlerts, err := fetchCodeScanningAlerts(repoFullName)
|
||||
if err != nil {
|
||||
|
|
@ -152,11 +152,11 @@ func runScan() error {
|
|||
for _, alert := range allAlerts {
|
||||
severityRenderer := severityStyle(alert.Severity)
|
||||
|
||||
location := fmt.Sprintf("%s:%d", alert.Path, alert.Line)
|
||||
location := core.Sprintf("%s:%d", alert.Path, alert.Line)
|
||||
|
||||
cli.Print("%-16s %s %-20s %-40s %s\n",
|
||||
cli.ValueStyle.Render(alert.Repo),
|
||||
severityRenderer.Render(fmt.Sprintf("%-8s", alert.Severity)),
|
||||
severityRenderer.Render(core.Sprintf("%-8s", alert.Severity)),
|
||||
alert.RuleID,
|
||||
location,
|
||||
cli.DimStyle.Render(alert.Tool),
|
||||
|
|
@ -244,10 +244,10 @@ func runScanForTarget(target string) error {
|
|||
|
||||
for _, alert := range allAlerts {
|
||||
severityRenderer := severityStyle(alert.Severity)
|
||||
location := fmt.Sprintf("%s:%d", alert.Path, alert.Line)
|
||||
location := core.Sprintf("%s:%d", alert.Path, alert.Line)
|
||||
cli.Print("%-16s %s %-20s %-40s %s\n",
|
||||
cli.ValueStyle.Render(alert.Repo),
|
||||
severityRenderer.Render(fmt.Sprintf("%-8s", alert.Severity)),
|
||||
severityRenderer.Render(core.Sprintf("%-8s", alert.Severity)),
|
||||
alert.RuleID,
|
||||
location,
|
||||
cli.DimStyle.Render(alert.Tool),
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ package security
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
"dappco.re/go/core/i18n"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
)
|
||||
|
|
@ -68,7 +68,7 @@ func runSecrets() error {
|
|||
openCount := 0
|
||||
|
||||
for _, repo := range repoList {
|
||||
repoFullName := fmt.Sprintf("%s/%s", registry.Org, repo.Name)
|
||||
repoFullName := core.Sprintf("%s/%s", registry.Org, repo.Name)
|
||||
|
||||
secretScanningAlerts, err := fetchSecretScanningAlerts(repoFullName)
|
||||
if err != nil {
|
||||
|
|
@ -103,7 +103,7 @@ func runSecrets() error {
|
|||
|
||||
cli.Blank()
|
||||
if openCount > 0 {
|
||||
cli.Print("%s %s\n", cli.DimStyle.Render("Secrets:"), cli.ErrorStyle.Render(fmt.Sprintf("%d open", openCount)))
|
||||
cli.Print("%s %s\n", cli.DimStyle.Render("Secrets:"), cli.ErrorStyle.Render(core.Sprintf("%d open", openCount)))
|
||||
} else {
|
||||
cli.Print("%s %s\n", cli.DimStyle.Render("Secrets:"), cli.SuccessStyle.Render("No exposed secrets"))
|
||||
}
|
||||
|
|
@ -174,7 +174,7 @@ func runSecretsForTarget(target string) error {
|
|||
|
||||
cli.Blank()
|
||||
if openCount > 0 {
|
||||
cli.Print("%s %s\n", cli.DimStyle.Render("Secrets ("+fullName+"):"), cli.ErrorStyle.Render(fmt.Sprintf("%d open", openCount)))
|
||||
cli.Print("%s %s\n", cli.DimStyle.Render("Secrets ("+fullName+"):"), cli.ErrorStyle.Render(core.Sprintf("%d open", openCount)))
|
||||
} else {
|
||||
cli.Print("%s %s\n", cli.DimStyle.Render("Secrets ("+fullName+"):"), cli.SuccessStyle.Render("No exposed secrets"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
package security
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
"dappco.re/go/core/i18n"
|
||||
"dappco.re/go/core/io"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
|
|
@ -155,10 +154,10 @@ func runGHAPI(endpoint string) ([]byte, error) {
|
|||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
stderr := string(exitErr.Stderr)
|
||||
if strings.Contains(stderr, "404") || strings.Contains(stderr, "Not Found") {
|
||||
if core.Contains(stderr, "404") || core.Contains(stderr, "Not Found") {
|
||||
return []byte("[]"), nil
|
||||
}
|
||||
if strings.Contains(stderr, "403") {
|
||||
if core.Contains(stderr, "403") {
|
||||
return nil, coreerr.E("security.runGHAPI", "access denied (check token permissions)", nil)
|
||||
}
|
||||
}
|
||||
|
|
@ -172,7 +171,7 @@ func runGHAPI(endpoint string) ([]byte, error) {
|
|||
// severityStyle("critical") // → cli.ErrorStyle
|
||||
// severityStyle("high") // → cli.WarningStyle
|
||||
func severityStyle(severity string) *cli.AnsiStyle {
|
||||
switch strings.ToLower(severity) {
|
||||
switch core.Lower(severity) {
|
||||
case "critical":
|
||||
return cli.ErrorStyle
|
||||
case "high":
|
||||
|
|
@ -194,9 +193,9 @@ func filterBySeverity(severity, filter string) bool {
|
|||
return true
|
||||
}
|
||||
|
||||
severityLower := strings.ToLower(severity)
|
||||
return slices.ContainsFunc(slices.Collect(strings.SplitSeq(strings.ToLower(filter), ",")), func(filterEntry string) bool {
|
||||
return strings.TrimSpace(filterEntry) == severityLower
|
||||
severityLower := core.Lower(severity)
|
||||
return slices.ContainsFunc(core.Split(core.Lower(filter), ","), func(filterEntry string) bool {
|
||||
return core.Trim(filterEntry) == severityLower
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -218,7 +217,7 @@ func getReposToCheck(registry *repos.Registry, repoFilter string) []*repos.Repo
|
|||
//
|
||||
// buildTargetRepo("wailsapp/wails") // → &Repo{Name:"wails"}, "wailsapp/wails"
|
||||
func buildTargetRepo(target string) (*repos.Repo, string) {
|
||||
targetParts := strings.SplitN(target, "/", 2)
|
||||
targetParts := core.SplitN(target, "/", 2)
|
||||
if len(targetParts) != 2 || targetParts[0] == "" || targetParts[1] == "" {
|
||||
return nil, ""
|
||||
}
|
||||
|
|
@ -244,7 +243,7 @@ type AlertSummary struct {
|
|||
// summary.Add("critical") // summary.Critical == 1, summary.Total == 1
|
||||
func (summary *AlertSummary) Add(severity string) {
|
||||
summary.Total++
|
||||
switch strings.ToLower(severity) {
|
||||
switch core.Lower(severity) {
|
||||
case "critical":
|
||||
summary.Critical++
|
||||
case "high":
|
||||
|
|
@ -264,22 +263,22 @@ func (summary *AlertSummary) Add(severity string) {
|
|||
func (summary *AlertSummary) String() string {
|
||||
segments := []string{}
|
||||
if summary.Critical > 0 {
|
||||
segments = append(segments, cli.ErrorStyle.Render(fmt.Sprintf("%d critical", summary.Critical)))
|
||||
segments = append(segments, cli.ErrorStyle.Render(core.Sprintf("%d critical", summary.Critical)))
|
||||
}
|
||||
if summary.High > 0 {
|
||||
segments = append(segments, cli.WarningStyle.Render(fmt.Sprintf("%d high", summary.High)))
|
||||
segments = append(segments, cli.WarningStyle.Render(core.Sprintf("%d high", summary.High)))
|
||||
}
|
||||
if summary.Medium > 0 {
|
||||
segments = append(segments, cli.ValueStyle.Render(fmt.Sprintf("%d medium", summary.Medium)))
|
||||
segments = append(segments, cli.ValueStyle.Render(core.Sprintf("%d medium", summary.Medium)))
|
||||
}
|
||||
if summary.Low > 0 {
|
||||
segments = append(segments, cli.DimStyle.Render(fmt.Sprintf("%d low", summary.Low)))
|
||||
segments = append(segments, cli.DimStyle.Render(core.Sprintf("%d low", summary.Low)))
|
||||
}
|
||||
if summary.Unknown > 0 {
|
||||
segments = append(segments, cli.DimStyle.Render(fmt.Sprintf("%d unknown", summary.Unknown)))
|
||||
segments = append(segments, cli.DimStyle.Render(core.Sprintf("%d unknown", summary.Unknown)))
|
||||
}
|
||||
if len(segments) == 0 {
|
||||
return cli.SuccessStyle.Render("No alerts")
|
||||
}
|
||||
return strings.Join(segments, " | ")
|
||||
return core.Join(" | ", segments...)
|
||||
}
|
||||
|
|
|
|||
273
cmd/security/cmd_security_test.go
Normal file
273
cmd/security/cmd_security_test.go
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
package security
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
)
|
||||
|
||||
// TestAlertSummary_Add_Good verifies that known severity levels increment the correct counter.
|
||||
func TestAlertSummary_Add_Good(t *testing.T) {
|
||||
summary := &AlertSummary{}
|
||||
|
||||
summary.Add("critical")
|
||||
summary.Add("high")
|
||||
summary.Add("medium")
|
||||
summary.Add("low")
|
||||
|
||||
if summary.Critical != 1 {
|
||||
t.Errorf("Critical: want 1, got %d", summary.Critical)
|
||||
}
|
||||
if summary.High != 1 {
|
||||
t.Errorf("High: want 1, got %d", summary.High)
|
||||
}
|
||||
if summary.Medium != 1 {
|
||||
t.Errorf("Medium: want 1, got %d", summary.Medium)
|
||||
}
|
||||
if summary.Low != 1 {
|
||||
t.Errorf("Low: want 1, got %d", summary.Low)
|
||||
}
|
||||
if summary.Total != 4 {
|
||||
t.Errorf("Total: want 4, got %d", summary.Total)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAlertSummary_Add_Bad verifies that an unrecognised severity goes to the Unknown bucket.
|
||||
func TestAlertSummary_Add_Bad(t *testing.T) {
|
||||
summary := &AlertSummary{}
|
||||
|
||||
summary.Add("informational")
|
||||
summary.Add("WEIRD")
|
||||
|
||||
if summary.Unknown != 2 {
|
||||
t.Errorf("Unknown: want 2, got %d", summary.Unknown)
|
||||
}
|
||||
if summary.Total != 2 {
|
||||
t.Errorf("Total: want 2, got %d", summary.Total)
|
||||
}
|
||||
if summary.Critical != 0 || summary.High != 0 {
|
||||
t.Errorf("Critical/High should remain 0 for unknown severities")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAlertSummary_Add_Ugly verifies case-insensitive matching — "CRITICAL" and "Critical" both hit Critical.
|
||||
func TestAlertSummary_Add_Ugly(t *testing.T) {
|
||||
summary := &AlertSummary{}
|
||||
|
||||
summary.Add("CRITICAL")
|
||||
summary.Add("Critical")
|
||||
summary.Add("HIGH")
|
||||
summary.Add("Medium")
|
||||
summary.Add("LOW")
|
||||
|
||||
if summary.Critical != 2 {
|
||||
t.Errorf("Critical: want 2, got %d", summary.Critical)
|
||||
}
|
||||
if summary.High != 1 {
|
||||
t.Errorf("High: want 1, got %d", summary.High)
|
||||
}
|
||||
if summary.Medium != 1 {
|
||||
t.Errorf("Medium: want 1, got %d", summary.Medium)
|
||||
}
|
||||
if summary.Low != 1 {
|
||||
t.Errorf("Low: want 1, got %d", summary.Low)
|
||||
}
|
||||
if summary.Total != 5 {
|
||||
t.Errorf("Total: want 5, got %d", summary.Total)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAlertSummaryString_Good verifies that String() renders all non-zero counts.
|
||||
func TestAlertSummaryString_Good(t *testing.T) {
|
||||
summary := &AlertSummary{Critical: 1, High: 2, Total: 3}
|
||||
result := summary.String()
|
||||
|
||||
if !core.Contains(result, "1 critical") {
|
||||
t.Errorf("expected '1 critical' in %q", result)
|
||||
}
|
||||
if !core.Contains(result, "2 high") {
|
||||
t.Errorf("expected '2 high' in %q", result)
|
||||
}
|
||||
if !core.Contains(result, "|") {
|
||||
t.Errorf("expected separator '|' between segments in %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAlertSummaryString_Bad verifies that an empty AlertSummary returns the "No alerts" message.
|
||||
func TestAlertSummaryString_Bad(t *testing.T) {
|
||||
summary := &AlertSummary{}
|
||||
result := summary.String()
|
||||
|
||||
if !core.Contains(result, "No alerts") {
|
||||
t.Errorf("expected 'No alerts' for empty summary, got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAlertSummaryString_Ugly verifies that zero-count severity levels are omitted from the string.
|
||||
func TestAlertSummaryString_Ugly(t *testing.T) {
|
||||
// Only medium is set — critical/high/low/unknown should not appear.
|
||||
summary := &AlertSummary{Medium: 3, Total: 3}
|
||||
result := summary.String()
|
||||
|
||||
if core.Contains(result, "critical") {
|
||||
t.Errorf("'critical' should be absent when count is 0, got %q", result)
|
||||
}
|
||||
if core.Contains(result, "high") {
|
||||
t.Errorf("'high' should be absent when count is 0, got %q", result)
|
||||
}
|
||||
if !core.Contains(result, "3 medium") {
|
||||
t.Errorf("expected '3 medium' in %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilterBySeverity_Good verifies that matching entries pass the filter.
|
||||
func TestFilterBySeverity_Good(t *testing.T) {
|
||||
cases := []struct {
|
||||
severity string
|
||||
filter string
|
||||
}{
|
||||
{"critical", "critical,high"},
|
||||
{"high", "critical,high"},
|
||||
{"medium", "medium"},
|
||||
{"low", "low,medium,high"},
|
||||
// Empty filter passes everything.
|
||||
{"critical", ""},
|
||||
{"anything", ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if !filterBySeverity(tc.severity, tc.filter) {
|
||||
t.Errorf("filterBySeverity(%q, %q): want true, got false", tc.severity, tc.filter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilterBySeverity_Bad verifies that non-matching severities are excluded.
|
||||
func TestFilterBySeverity_Bad(t *testing.T) {
|
||||
cases := []struct {
|
||||
severity string
|
||||
filter string
|
||||
}{
|
||||
{"low", "critical,high"},
|
||||
{"medium", "critical"},
|
||||
{"informational", "critical,high,medium,low"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if filterBySeverity(tc.severity, tc.filter) {
|
||||
t.Errorf("filterBySeverity(%q, %q): want false, got true", tc.severity, tc.filter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilterBySeverity_Ugly verifies case-insensitive matching and whitespace tolerance.
|
||||
func TestFilterBySeverity_Ugly(t *testing.T) {
|
||||
// Severity and filter are uppercased — must still match.
|
||||
if !filterBySeverity("CRITICAL", "CRITICAL,HIGH") {
|
||||
t.Error("filterBySeverity should be case-insensitive for severity")
|
||||
}
|
||||
if !filterBySeverity("critical", "CRITICAL") {
|
||||
t.Error("filterBySeverity should be case-insensitive for filter")
|
||||
}
|
||||
// Filter entries may have leading/trailing whitespace.
|
||||
if !filterBySeverity("high", " high , critical ") {
|
||||
t.Error("filterBySeverity should trim whitespace from filter entries")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildTargetRepo_Good verifies that a valid owner/repo string parses correctly.
|
||||
func TestBuildTargetRepo_Good(t *testing.T) {
|
||||
repo, fullName := buildTargetRepo("wailsapp/wails")
|
||||
if repo == nil {
|
||||
t.Fatal("expected non-nil repo")
|
||||
}
|
||||
if repo.Name != "wails" {
|
||||
t.Errorf("Name: want 'wails', got %q", repo.Name)
|
||||
}
|
||||
if fullName != "wailsapp/wails" {
|
||||
t.Errorf("fullName: want 'wailsapp/wails', got %q", fullName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildTargetRepo_Bad verifies that malformed targets return nil.
|
||||
func TestBuildTargetRepo_Bad(t *testing.T) {
|
||||
badTargets := []string{
|
||||
"", // empty
|
||||
"noslash", // missing separator
|
||||
"/repo", // empty owner
|
||||
"owner/", // empty repo
|
||||
"//double", // double slash
|
||||
}
|
||||
|
||||
for _, target := range badTargets {
|
||||
repo, fullName := buildTargetRepo(target)
|
||||
if repo != nil || fullName != "" {
|
||||
t.Errorf("buildTargetRepo(%q): expected (nil, ''), got (%v, %q)", target, repo, fullName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildTargetRepo_Ugly verifies targets with extra path segments — only the first two parts matter.
|
||||
func TestBuildTargetRepo_Ugly(t *testing.T) {
|
||||
// SplitN(n=2) means "owner/repo/extra" → ["owner", "repo/extra"]
|
||||
// repo name becomes "repo/extra" which is non-empty, so it parses.
|
||||
repo, fullName := buildTargetRepo("owner/repo/extra")
|
||||
if repo == nil {
|
||||
t.Fatal("expected non-nil repo for 3-segment path")
|
||||
}
|
||||
if repo.Name != "repo/extra" {
|
||||
t.Errorf("Name: want 'repo/extra', got %q", repo.Name)
|
||||
}
|
||||
if fullName != "owner/repo/extra" {
|
||||
t.Errorf("fullName: want 'owner/repo/extra', got %q", fullName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildJobIssueBody_Good verifies the issue body contains all required sections.
|
||||
func TestBuildJobIssueBody_Good(t *testing.T) {
|
||||
summary := &AlertSummary{Critical: 1, High: 2, Total: 3}
|
||||
findings := []string{"- [CRITICAL] tool: description (file.go:10)"}
|
||||
|
||||
body := buildJobIssueBody("wailsapp/wails", summary, findings)
|
||||
|
||||
requiredSections := []string{
|
||||
"## Security Scan: wailsapp/wails",
|
||||
"### Findings",
|
||||
"### Checklist",
|
||||
"### Instructions",
|
||||
"wailsapp/wails",
|
||||
}
|
||||
|
||||
for _, section := range requiredSections {
|
||||
if !core.Contains(body, section) {
|
||||
t.Errorf("body missing section %q", section)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildJobIssueBody_Bad verifies that more than 50 findings are truncated with a count line.
|
||||
func TestBuildJobIssueBody_Bad(t *testing.T) {
|
||||
summary := &AlertSummary{Total: 60}
|
||||
findings := make([]string, 60)
|
||||
for i := range 60 {
|
||||
findings[i] = "- [HIGH] finding"
|
||||
}
|
||||
|
||||
body := buildJobIssueBody("org/repo", summary, findings)
|
||||
|
||||
if !core.Contains(body, "... and 10 more") {
|
||||
t.Errorf("expected truncation message '... and 10 more' in body, got:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildJobIssueBody_Ugly verifies that empty findings still produces a valid body with all sections.
|
||||
func TestBuildJobIssueBody_Ugly(t *testing.T) {
|
||||
summary := &AlertSummary{}
|
||||
body := buildJobIssueBody("org/empty-repo", summary, nil)
|
||||
|
||||
for _, section := range []string{"### Findings", "### Checklist", "### Instructions"} {
|
||||
if !core.Contains(body, section) {
|
||||
t.Errorf("empty findings body missing section %q", section)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue