Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
59d9480
Fix bug with throwing away trailing data
wes-mil Apr 14, 2026
0b8b2ba
update go version
wes-mil Apr 14, 2026
03700a7
Add ParseMetadata
wes-mil Apr 28, 2026
adb5640
go mod tidy
wes-mil Apr 28, 2026
e3d7c5a
Merge branch 'main' into BED-7790
wes-mil Apr 28, 2026
a12efb9
Clean up comments
wes-mil Apr 28, 2026
8bc1dd8
it's the greatest commit to ever commit, delivering high quality code…
wes-mil Apr 30, 2026
9ffc316
Add an Error string to the validation error
wes-mil May 1, 2026
e256102
Reject payloads with kinds that start with "Tag_"
wes-mil May 4, 2026
e93bf81
Improve testing coverage
wes-mil May 26, 2026
edcccfb
Fix property name rejection
wes-mil May 26, 2026
9fef646
improve write string
wes-mil May 26, 2026
e09380c
update chowbench
wes-mil Jun 1, 2026
c26b346
uppercase property names allowed
wes-mil Aug 19, 2026
30fc4f0
allow restricted kinds in property matches
wes-mil Aug 19, 2026
d74ea1f
verify null properties are allowed. objectid is rejected on nodes and…
wes-mil Aug 19, 2026
3483918
allow unknown top level values
wes-mil Aug 19, 2026
3611965
update go version
wes-mil Aug 19, 2026
95768c2
Rename LegacyMetadata to OriginalMetadata
wes-mil Aug 19, 2026
79180c4
improve error messaging for no valid tags found
wes-mil Aug 19, 2026
bbe895e
remove public delimiters
wes-mil Aug 19, 2026
01c4684
improve ParsedData output
wes-mil Aug 19, 2026
451274c
Merge branch 'main' into BED-7790
wes-mil Aug 31, 2026
7aaa256
add licenses
wes-mil Aug 31, 2026
44cbd09
appease linter
wes-mil Aug 31, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
fixtures/
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,34 @@ Or you can clone the repo and run the following command from the top level:
go install .
```

# Benchmarking

Use `chowbench` when you want to measure validation performance across one or more files without changing the normal `chow` CLI.

```bash
go run ./cmd/chowbench -runs 5 -warmup 1 payload-one.json payload-two.json
```

The harness loads the JSON schemas once, validates each file for the requested number of runs, and prints a table with byte size, status, average duration, min/max duration, and error counts.

By default, invalid payloads are still measured and reported. Add `-strict` if invalid payloads should make the command exit non-zero:

```bash
go run ./cmd/chowbench -runs 5 -strict payload-one.json payload-two.json
```

# JSON Schema

Want to add the OpenGraph schema to your JSON document?

```json
{
"$schema": "https://raw.githubusercontent.com/SpecterOps/chow/refs/heads/main/pkg/validator/jsonschema/payload-schema.json"
"$schema": "https://raw.githubusercontent.com/SpecterOps/chow/refs/heads/main/pkg/payload/jsonschema/schema.json"
}
```

Most editors will ask you to trust the schema's source. Be sure to add the following URL to your trusted domains

```text
https://raw.githubusercontent.com/SpecterOps/chow/refs/heads/main/pkg/validator/jsonschema/
https://raw.githubusercontent.com/SpecterOps/chow/refs/heads/main/pkg/payload/jsonschema/
```
224 changes: 224 additions & 0 deletions cmd/chowbench/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
// Copyright 2026 Specter Ops, Inc.
//
// Licensed under the Apache License, Version 2.0
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0

package main

import (
"errors"
"flag"
"fmt"
"io"
"os"
"text/tabwriter"
"time"

"github.com/specterops/chow/pkg/payload"
)

type durationSummary struct {
Avg time.Duration
Min time.Duration
Max time.Duration
}

type benchmarkResult struct {
File string
Bytes int64
Runs int
Status string
Error string
CriticalErrors int
ValidationErrors int
Durations durationSummary
}

func main() {
var (
runs int
warmup int
strict bool
)

flag.IntVar(&runs, "runs", 3, "number of measured validation runs per file")
flag.IntVar(&warmup, "warmup", 1, "number of unmeasured warmup validation runs per file")
flag.BoolVar(&strict, "strict", false, "exit non-zero when a file fails validation")
flag.Parse()

if err := run(os.Stdout, flag.Args(), runs, warmup, strict); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

func run(w io.Writer, files []string, runs int, warmup int, strict bool) error {
if runs < 1 {
return fmt.Errorf("-runs must be greater than 0")
}
if warmup < 0 {
return fmt.Errorf("-warmup must be 0 or greater")
}
if len(files) == 0 {
return fmt.Errorf("usage: chowbench [-runs N] [-warmup N] [-strict] file [file...]")
}

schema, err := payload.LoadSchema()
if err != nil {
return fmt.Errorf("load schema: %w", err)
}

results := make([]benchmarkResult, 0, len(files))
for _, file := range files {
result := benchmarkFile(file, schema, runs, warmup)
results = append(results, result)
}

if err := writeResults(w, results); err != nil {
return err
}
return exitErrorForResults(results, strict)
}

func benchmarkFile(file string, schema payload.Schema, runs int, warmup int) benchmarkResult {
result := benchmarkResult{
File: file,
Runs: runs,
}

if stat, err := os.Stat(file); err != nil {
result.Status = "error"
result.Error = err.Error()
return result
} else {
result.Bytes = stat.Size()
}

for i := 0; i < warmup; i++ {
_, _ = validateFile(file, schema)
}

durations := make([]time.Duration, 0, runs)
for i := 0; i < runs; i++ {
start := time.Now()
report, err := validateFile(file, schema)
durations = append(durations, time.Since(start))

result.Status, result.Error = statusForValidationResult(report, err)
result.CriticalErrors = len(report.CriticalErrors)
result.ValidationErrors = len(report.ValidationErrors)
}
Comment thread
wes-mil marked this conversation as resolved.

result.Durations = summarizeDurations(durations)
return result
}

func validateFile(file string, schema payload.Schema) (payload.ValidationReport, error) {
reader, err := os.Open(file)
if err != nil {
return payload.ValidationReport{}, err
}
defer reader.Close()

validator := payload.NewValidator(reader, schema)
_, report, err := validator.ParseAndValidate()
return report, err
}

func summarizeDurations(durations []time.Duration) durationSummary {
if len(durations) == 0 {
return durationSummary{}
}

var total time.Duration
summary := durationSummary{
Min: durations[0],
Max: durations[0],
}

for _, duration := range durations {
total += duration
if duration < summary.Min {
summary.Min = duration
}
if duration > summary.Max {
summary.Max = duration
}
}

summary.Avg = total / time.Duration(len(durations))
return summary
}

func statusForValidationResult(report payload.ValidationReport, err error) (string, string) {
if err == nil {
return "ok", ""
}

if len(report.CriticalErrors) > 0 {
return "critical_error", err.Error()
}

if len(report.ValidationErrors) > 0 ||
errors.Is(err, payload.ErrValidationErrors) ||
errors.Is(err, payload.ErrMaxValidationErrors) {
return "validation_error", err.Error()
}

return "error", err.Error()
}

func exitErrorForResults(results []benchmarkResult, strict bool) error {
var hasValidationFailure bool
for _, result := range results {
switch result.Status {
case "error":
return fmt.Errorf("one or more files could not be benchmarked")
case "validation_error", "critical_error":
hasValidationFailure = true
}
}

if strict && hasValidationFailure {
return fmt.Errorf("one or more files failed validation")
}

return nil
}

func writeResults(w io.Writer, results []benchmarkResult) error {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
if _, err := fmt.Fprintln(tw, "file\tbytes\truns\tstatus\tavg\tmin\tmax\tcritical\tvalidation\terror"); err != nil {
return err
}
for _, result := range results {
if _, err := fmt.Fprintf(
tw,
"%s\t%d\t%d\t%s\t%s\t%s\t%s\t%d\t%d\t%s\n",
result.File,
result.Bytes,
result.Runs,
result.Status,
result.Durations.Avg,
result.Durations.Min,
result.Durations.Max,
result.CriticalErrors,
result.ValidationErrors,
result.Error,
); err != nil {
return err
}
}
return tw.Flush()
}
Loading
Loading