← back to Cli Printing Press
feat(websniff): add auth session capture with domain binding and security hardening
91e04cca82c5701c5967646a860e8fac9ba57b96 · 2026-03-28 22:23:24 -0700 · Matt Van Horn
- AuthCapture struct with Headers, Cookies, Type, BoundDomain, ExpiresAt
- detectAuth() prefers explicitly captured auth over header inference
- --auth-from flag on sniff command injects pre-captured auth
- Domain binding: refuses cross-origin auth replay with clear error
- Cookie auth scoped to informational only (no template support)
- Test fixtures for Spotify-like authenticated capture scenarios
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Files touched
M internal/cli/sniff.goA internal/cli/sniff_test.goM internal/spec/spec.goA internal/websniff/capture.goM internal/websniff/parser.goM internal/websniff/specgen.goM internal/websniff/specgen_test.goM internal/websniff/types.goA testdata/sniff/sample-auth-capture-mismatch.jsonA testdata/sniff/sample-auth-capture.json
Diff
commit 91e04cca82c5701c5967646a860e8fac9ba57b96
Author: Matt Van Horn <mvanhorn@MacBook-Pro.local>
Date: Sat Mar 28 22:23:24 2026 -0700
feat(websniff): add auth session capture with domain binding and security hardening
- AuthCapture struct with Headers, Cookies, Type, BoundDomain, ExpiresAt
- detectAuth() prefers explicitly captured auth over header inference
- --auth-from flag on sniff command injects pre-captured auth
- Domain binding: refuses cross-origin auth replay with clear error
- Cookie auth scoped to informational only (no template support)
- Test fixtures for Spotify-like authenticated capture scenarios
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
internal/cli/sniff.go | 85 +++++++++++++++++++++-
internal/cli/sniff_test.go | 25 +++++++
internal/spec/spec.go | 2 +-
internal/websniff/capture.go | 73 +++++++++++++++++++
internal/websniff/parser.go | 46 ++----------
internal/websniff/specgen.go | 91 ++++++++++++++++++++++--
internal/websniff/specgen_test.go | 91 ++++++++++++++++++++++++
internal/websniff/types.go | 9 +++
testdata/sniff/sample-auth-capture-mismatch.json | 29 ++++++++
testdata/sniff/sample-auth-capture.json | 30 ++++++++
10 files changed, 432 insertions(+), 49 deletions(-)
diff --git a/internal/cli/sniff.go b/internal/cli/sniff.go
index 14920cb6..4ae44ea6 100644
--- a/internal/cli/sniff.go
+++ b/internal/cli/sniff.go
@@ -2,6 +2,7 @@ package cli
import (
"fmt"
+ "net/url"
"strings"
"github.com/mvanhorn/cli-printing-press/internal/websniff"
@@ -13,6 +14,7 @@ func newSniffCmd() *cobra.Command {
var outputPath string
var name string
var blocklist string
+ var authFrom string
cmd := &cobra.Command{
Use: "sniff",
@@ -20,7 +22,23 @@ func newSniffCmd() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
websniff.SetAdditionalBlocklist(splitCSV(blocklist))
- apiSpec, err := websniff.Analyze(harPath)
+ capture, err := websniff.LoadCapture(harPath)
+ if err != nil {
+ return fmt.Errorf("loading capture: %w", err)
+ }
+
+ if authFrom != "" {
+ authCapture, err := websniff.ParseEnriched(authFrom)
+ if err != nil {
+ return fmt.Errorf("reading auth capture: %w", err)
+ }
+ if err := validateAuthDomainBinding(authCapture, capture); err != nil {
+ return err
+ }
+ capture.Auth = authCapture.Auth
+ }
+
+ apiSpec, err := websniff.AnalyzeCapture(capture)
if err != nil {
return fmt.Errorf("analyzing capture: %w", err)
}
@@ -53,6 +71,7 @@ func newSniffCmd() *cobra.Command {
cmd.Flags().StringVar(&outputPath, "output", "", "Output path for generated spec YAML")
cmd.Flags().StringVar(&name, "name", "", "Override the auto-detected API name")
cmd.Flags().StringVar(&blocklist, "blocklist", "", "Comma-separated additional domains to filter")
+ cmd.Flags().StringVar(&authFrom, "auth-from", "", "Path to an enriched capture file to import auth from")
_ = cmd.MarkFlagRequired("har")
return cmd
@@ -75,3 +94,67 @@ func splitCSV(value string) []string {
return out
}
+
+func validateAuthDomainBinding(authCapture *websniff.EnrichedCapture, targetCapture *websniff.EnrichedCapture) error {
+ if authCapture == nil || authCapture.Auth == nil || strings.TrimSpace(authCapture.Auth.BoundDomain) == "" {
+ return nil
+ }
+
+ targetDomain := captureDomain(targetCapture)
+ boundDomain := normalizeDomain(authCapture.Auth.BoundDomain)
+ if targetDomain == "" || boundDomain == "" {
+ return nil
+ }
+ if targetDomain == boundDomain || strings.HasSuffix(targetDomain, "."+boundDomain) {
+ return nil
+ }
+
+ return fmt.Errorf("auth captured for %s cannot be used with %s (domain mismatch)", authCapture.Auth.BoundDomain, targetDomain)
+}
+
+func captureDomain(capture *websniff.EnrichedCapture) string {
+ if capture == nil {
+ return ""
+ }
+
+ if capture.TargetURL != "" {
+ parsed, err := url.Parse(capture.TargetURL)
+ if err == nil && parsed.Hostname() != "" {
+ return normalizeDomain(parsed.Hostname())
+ }
+ }
+
+ baseURL := commonCaptureBaseURL(capture)
+ parsed, err := url.Parse(baseURL)
+ if err != nil {
+ return ""
+ }
+
+ return normalizeDomain(parsed.Hostname())
+}
+
+func commonCaptureBaseURL(capture *websniff.EnrichedCapture) string {
+ counts := make(map[string]int)
+ best := ""
+ bestCount := 0
+
+ for _, entry := range capture.Entries {
+ parsed, err := url.Parse(entry.URL)
+ if err != nil || parsed.Scheme == "" || parsed.Host == "" {
+ continue
+ }
+
+ base := parsed.Scheme + "://" + parsed.Host
+ counts[base]++
+ if counts[base] > bestCount {
+ best = base
+ bestCount = counts[base]
+ }
+ }
+
+ return best
+}
+
+func normalizeDomain(domain string) string {
+ return strings.ToLower(strings.TrimSpace(domain))
+}
diff --git a/internal/cli/sniff_test.go b/internal/cli/sniff_test.go
new file mode 100644
index 00000000..1ef6169b
--- /dev/null
+++ b/internal/cli/sniff_test.go
@@ -0,0 +1,25 @@
+package cli
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSniffCmdRejectsDomainMismatchOnAuthFrom(t *testing.T) {
+ t.Parallel()
+
+ cmd := newSniffCmd()
+ outputPath := filepath.Join(t.TempDir(), "spec.yaml")
+ cmd.SetArgs([]string{
+ "--har", filepath.Join("..", "..", "testdata", "sniff", "sample-enriched.json"),
+ "--auth-from", filepath.Join("..", "..", "testdata", "sniff", "sample-auth-capture-mismatch.json"),
+ "--output", outputPath,
+ })
+
+ err := cmd.Execute()
+ require.Error(t, err)
+ assert.EqualError(t, err, "auth captured for other.example.com cannot be used with hn.algolia.com (domain mismatch)")
+}
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index f1085fdd..7eb73cca 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -21,7 +21,7 @@ type APISpec struct {
}
type AuthConfig struct {
- Type string `yaml:"type"` // api_key, oauth2, bearer_token, none
+ Type string `yaml:"type"` // api_key, oauth2, bearer_token, cookie, none
Header string `yaml:"header"`
Format string `yaml:"format"`
EnvVars []string `yaml:"env_vars"`
diff --git a/internal/websniff/capture.go b/internal/websniff/capture.go
new file mode 100644
index 00000000..7635be74
--- /dev/null
+++ b/internal/websniff/capture.go
@@ -0,0 +1,73 @@
+package websniff
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+func LoadCapture(path string) (*EnrichedCapture, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("reading file: %w", err)
+ }
+
+ if bytes.Contains(data, []byte(`"log"`)) {
+ var har HAR
+ if err := json.Unmarshal(data, &har); err != nil {
+ return nil, fmt.Errorf("parsing har json: %w", err)
+ }
+
+ capture := &EnrichedCapture{
+ Entries: make([]EnrichedEntry, 0, len(har.Log.Entries)),
+ }
+ for _, entry := range har.Log.Entries {
+ capture.Entries = append(capture.Entries, convertHAREntry(entry))
+ }
+ if len(har.Log.Entries) > 0 {
+ capture.TargetURL = har.Log.Entries[0].Request.URL
+ }
+
+ return capture, nil
+ }
+
+ if bytes.Contains(data, []byte(`"target_url"`)) {
+ var capture EnrichedCapture
+ if err := json.Unmarshal(data, &capture); err != nil {
+ return nil, fmt.Errorf("parsing enriched json: %w", err)
+ }
+
+ return &capture, nil
+ }
+
+ return nil, fmt.Errorf("unknown capture format")
+}
+
+func WriteEnrichedCapture(capture *EnrichedCapture, outputPath string) error {
+ if capture == nil {
+ return fmt.Errorf("capture is required")
+ }
+
+ data, err := json.MarshalIndent(capture, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshaling enriched json: %w", err)
+ }
+
+ if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
+ return fmt.Errorf("creating output directory: %w", err)
+ }
+
+ file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
+ if err != nil {
+ return fmt.Errorf("opening output file: %w", err)
+ }
+ defer file.Close()
+
+ if _, err := file.Write(data); err != nil {
+ return fmt.Errorf("writing enriched json: %w", err)
+ }
+
+ return nil
+}
diff --git a/internal/websniff/parser.go b/internal/websniff/parser.go
index 7d1ba2f2..f025cc7c 100644
--- a/internal/websniff/parser.go
+++ b/internal/websniff/parser.go
@@ -1,7 +1,6 @@
package websniff
import (
- "bytes"
"encoding/json"
"fmt"
"os"
@@ -22,54 +21,21 @@ func ParseHAR(path string) (*HAR, error) {
}
func ParseEnriched(path string) (*EnrichedCapture, error) {
- data, err := os.ReadFile(path)
+ capture, err := LoadCapture(path)
if err != nil {
- return nil, fmt.Errorf("reading file: %w", err)
- }
-
- var capture EnrichedCapture
- if err := json.Unmarshal(data, &capture); err != nil {
- return nil, fmt.Errorf("parsing enriched json: %w", err)
+ return nil, err
}
- return &capture, nil
+ return capture, nil
}
func ParseCapture(path string) ([]EnrichedEntry, string, error) {
- data, err := os.ReadFile(path)
+ capture, err := LoadCapture(path)
if err != nil {
- return nil, "", fmt.Errorf("reading file: %w", err)
- }
-
- if bytes.Contains(data, []byte(`"log"`)) {
- var har HAR
- if err := json.Unmarshal(data, &har); err != nil {
- return nil, "", fmt.Errorf("parsing har json: %w", err)
- }
-
- entries := make([]EnrichedEntry, 0, len(har.Log.Entries))
- for _, entry := range har.Log.Entries {
- entries = append(entries, convertHAREntry(entry))
- }
-
- targetURL := ""
- if len(har.Log.Entries) > 0 {
- targetURL = har.Log.Entries[0].Request.URL
- }
-
- return entries, targetURL, nil
- }
-
- if bytes.Contains(data, []byte(`"target_url"`)) {
- var capture EnrichedCapture
- if err := json.Unmarshal(data, &capture); err != nil {
- return nil, "", fmt.Errorf("parsing enriched json: %w", err)
- }
-
- return capture.Entries, capture.TargetURL, nil
+ return nil, "", err
}
- return nil, "", fmt.Errorf("unknown capture format")
+ return capture.Entries, capture.TargetURL, nil
}
func convertHAREntry(entry HAREntry) EnrichedEntry {
diff --git a/internal/websniff/specgen.go b/internal/websniff/specgen.go
index bc0ddfa8..441cbb84 100644
--- a/internal/websniff/specgen.go
+++ b/internal/websniff/specgen.go
@@ -14,12 +14,20 @@ import (
)
func Analyze(capturePath string) (*spec.APISpec, error) {
- entries, targetURL, err := ParseCapture(capturePath)
+ capture, err := LoadCapture(capturePath)
if err != nil {
return nil, err
}
- apiEntries, _ := ClassifyEntries(entries)
+ return AnalyzeCapture(capture)
+}
+
+func AnalyzeCapture(capture *EnrichedCapture) (*spec.APISpec, error) {
+ if capture == nil {
+ return nil, fmt.Errorf("capture is required")
+ }
+
+ apiEntries, _ := ClassifyEntries(capture.Entries)
groups := DeduplicateEndpoints(apiEntries)
resources := make(map[string]spec.Resource)
@@ -49,10 +57,10 @@ func Analyze(capturePath string) (*spec.APISpec, error) {
baseURL := mostCommonBaseURL(apiEntries)
if baseURL == "" {
- baseURL = normalizeBaseURL(targetURL)
+ baseURL = normalizeBaseURL(capture.TargetURL)
}
- nameSource := targetURL
+ nameSource := capture.TargetURL
if nameSource == "" {
nameSource = baseURL
}
@@ -63,7 +71,7 @@ func Analyze(capturePath string) (*spec.APISpec, error) {
Description: fmt.Sprintf("Discovered API spec for %s", name),
Version: "0.1.0",
BaseURL: baseURL,
- Auth: detectAuth(apiEntries, name),
+ Auth: detectAuth(capture, apiEntries, name),
Config: spec.ConfigSpec{
Format: "toml",
Path: fmt.Sprintf("~/.config/%s-pp-cli/config.toml", name),
@@ -130,7 +138,7 @@ func buildEndpoint(group EndpointGroup) spec.Endpoint {
body := inferRequestBody(group.Entries)
params := inferURLParams(group.Entries, group.NormalizedPath)
- auth := detectAuth(group.Entries, "")
+ auth := detectAuth(nil, group.Entries, "")
if auth.Type == "api_key" && strings.EqualFold(auth.In, "query") && auth.Header != "" {
params = filterAuthQueryParam(params, auth.Header)
}
@@ -232,8 +240,15 @@ func inferURLParams(entries []EnrichedEntry, normalizedPath string) []spec.Param
return params
}
-func detectAuth(entries []EnrichedEntry, name string) spec.AuthConfig {
+func detectAuth(capture *EnrichedCapture, entries []EnrichedEntry, name string) spec.AuthConfig {
envPrefix := strings.ToUpper(strings.ReplaceAll(name, "-", "_"))
+ if capture != nil && capture.Auth != nil {
+ auth := detectCapturedAuth(capture.Auth, envPrefix)
+ if auth.Type != "" {
+ return auth
+ }
+ }
+
for _, entry := range entries {
for headerName, value := range entry.RequestHeaders {
lowerHeader := strings.ToLower(headerName)
@@ -274,6 +289,68 @@ func detectAuth(entries []EnrichedEntry, name string) spec.AuthConfig {
return spec.AuthConfig{Type: "none"}
}
+func detectCapturedAuth(capture *AuthCapture, envPrefix string) spec.AuthConfig {
+ if capture == nil {
+ return spec.AuthConfig{}
+ }
+
+ captureType := strings.ToLower(strings.TrimSpace(capture.Type))
+ switch {
+ case len(capture.Headers) > 0:
+ switch captureType {
+ case "bearer":
+ return spec.AuthConfig{
+ Type: "bearer_token",
+ Header: "Authorization",
+ EnvVars: envVarsOrNil(envPrefix, "TOKEN"),
+ }
+ case "api_key":
+ headerName := firstAuthHeader(capture.Headers)
+ if headerName == "" {
+ headerName = "X-API-Key"
+ }
+ return spec.AuthConfig{
+ Type: "api_key",
+ Header: headerName,
+ In: "header",
+ EnvVars: envVarsOrNil(envPrefix, "API_KEY"),
+ }
+ case "cookie":
+ return spec.AuthConfig{
+ Type: "cookie",
+ Header: "Cookie",
+ In: "cookie",
+ Format: "informational only; no template support",
+ }
+ }
+ case captureType == "cookie" && len(capture.Cookies) > 0:
+ return spec.AuthConfig{
+ Type: "cookie",
+ Header: "Cookie",
+ In: "cookie",
+ Format: "informational only; no template support",
+ }
+ }
+
+ return spec.AuthConfig{}
+}
+
+func firstAuthHeader(headers map[string]string) string {
+ for _, preferred := range []string{"Authorization", "X-API-Key", "Api-Key", "X-Auth-Token"} {
+ for name := range headers {
+ if strings.EqualFold(name, preferred) {
+ return name
+ }
+ }
+ }
+
+ for name := range headers {
+ return name
+ }
+
+ return ""
+}
+
func envVarsOrNil(prefix string, suffix string) []string {
if prefix == "" {
return nil
diff --git a/internal/websniff/specgen_test.go b/internal/websniff/specgen_test.go
index 797597b6..c3cccd08 100644
--- a/internal/websniff/specgen_test.go
+++ b/internal/websniff/specgen_test.go
@@ -110,3 +110,94 @@ func TestDeriveNameFromURL(t *testing.T) {
})
}
}
+
+func TestAnalyzeCapture_UsesCapturedBearerAuth(t *testing.T) {
+ t.Parallel()
+
+ capture, err := ParseEnriched(filepath.Join("..", "..", "testdata", "sniff", "sample-auth-capture.json"))
+ require.NoError(t, err)
+
+ apiSpec, err := AnalyzeCapture(capture)
+ require.NoError(t, err)
+
+ assert.Equal(t, "bearer_token", apiSpec.Auth.Type)
+ assert.Equal(t, "Authorization", apiSpec.Auth.Header)
+}
+
+func TestAnalyzeCapture_UsesCapturedCookieAuth(t *testing.T) {
+ t.Parallel()
+
+ capture := &EnrichedCapture{
+ TargetURL: "https://api.spotify.com",
+ Auth: &AuthCapture{
+ Cookies: []string{"_session=abc"},
+ Type: "cookie",
+ BoundDomain: "spotify.com",
+ },
+ Entries: []EnrichedEntry{
+ {
+ Method: "GET",
+ URL: "https://api.spotify.com/v1/me",
+ RequestHeaders: map[string]string{"Content-Type": "application/json"},
+ },
+ },
+ }
+
+ apiSpec, err := AnalyzeCapture(capture)
+ require.NoError(t, err)
+
+ assert.Equal(t, "cookie", apiSpec.Auth.Type)
+ assert.Equal(t, "Cookie", apiSpec.Auth.Header)
+ assert.Equal(t, "cookie", apiSpec.Auth.In)
+ assert.Equal(t, "informational only; no template support", apiSpec.Auth.Format)
+}
+
+func TestDetectAuth_PrefersCapturedAuthOverHeaders(t *testing.T) {
+ t.Parallel()
+
+ auth := detectAuth(&EnrichedCapture{
+ Auth: &AuthCapture{
+ Headers: map[string]string{"X-API-Key": "key-123"},
+ Type: "api_key",
+ },
+ }, []EnrichedEntry{
+ {
+ RequestHeaders: map[string]string{"Authorization": "Bearer tok123"},
+ },
+ }, "spotify")
+
+ assert.Equal(t, "api_key", auth.Type)
+ assert.Equal(t, "X-API-Key", auth.Header)
+ assert.Equal(t, "header", auth.In)
+}
+
+func TestDetectAuth_FallsBackToHeaderInference(t *testing.T) {
+ t.Parallel()
+
+ auth := detectAuth(nil, []EnrichedEntry{
+ {
+ RequestHeaders: map[string]string{"Authorization": "Bearer tok123"},
+ },
+ }, "spotify")
+
+ assert.Equal(t, "bearer_token", auth.Type)
+ assert.Equal(t, "Authorization", auth.Header)
+}
+
+func TestWriteEnrichedCaptureUsesPrivatePermissions(t *testing.T) {
+ t.Parallel()
+
+ outputPath := filepath.Join(t.TempDir(), "capture.json")
+ err := WriteEnrichedCapture(&EnrichedCapture{
+ TargetURL: "https://api.spotify.com",
+ Auth: &AuthCapture{
+ Headers: map[string]string{"Authorization": "Bearer tok123"},
+ Type: "bearer",
+ },
+ }, outputPath)
+ require.NoError(t, err)
+
+ info, err := os.Stat(outputPath)
+ require.NoError(t, err)
+ assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
+}
diff --git a/internal/websniff/types.go b/internal/websniff/types.go
index 48597dc2..cc8edd9a 100644
--- a/internal/websniff/types.go
+++ b/internal/websniff/types.go
@@ -45,9 +45,18 @@ type EnrichedCapture struct {
TargetURL string `json:"target_url"`
CapturedAt string `json:"captured_at"`
InteractionRounds int `json:"interaction_rounds"`
+ Auth *AuthCapture `json:"auth,omitempty"`
Entries []EnrichedEntry `json:"entries"`
}
+type AuthCapture struct {
+ Headers map[string]string `json:"headers"`
+ Cookies []string `json:"cookies"`
+ Type string `json:"type"`
+ BoundDomain string `json:"bound_domain"`
+ ExpiresAt string `json:"expires_at"`
+}
+
type EnrichedEntry struct {
Method string `json:"method"`
URL string `json:"url"`
diff --git a/testdata/sniff/sample-auth-capture-mismatch.json b/testdata/sniff/sample-auth-capture-mismatch.json
new file mode 100644
index 00000000..439b2b39
--- /dev/null
+++ b/testdata/sniff/sample-auth-capture-mismatch.json
@@ -0,0 +1,29 @@
+{
+ "target_url": "https://other.example.com",
+ "captured_at": "2026-03-28T20:00:00Z",
+ "interaction_rounds": 1,
+ "auth": {
+ "headers": {
+ "Authorization": "Bearer test-token-123"
+ },
+ "cookies": [],
+ "type": "bearer",
+ "bound_domain": "other.example.com",
+ "expires_at": "2026-03-28T21:00:00Z"
+ },
+ "entries": [
+ {
+ "method": "GET",
+ "url": "https://other.example.com/v1/me",
+ "request_headers": {
+ "Authorization": "Bearer test-token-123"
+ },
+ "request_body": "",
+ "response_status": 200,
+ "response_content_type": "application/json",
+ "response_body": "{\"ok\":true}",
+ "classification": "api",
+ "is_noise": false
+ }
+ ]
+}
diff --git a/testdata/sniff/sample-auth-capture.json b/testdata/sniff/sample-auth-capture.json
new file mode 100644
index 00000000..09da1f65
--- /dev/null
+++ b/testdata/sniff/sample-auth-capture.json
@@ -0,0 +1,30 @@
+{
+ "target_url": "https://api.spotify.com",
+ "captured_at": "2026-03-28T20:00:00Z",
+ "interaction_rounds": 1,
+ "auth": {
+ "headers": {
+ "Authorization": "Bearer test-token-123"
+ },
+ "cookies": [],
+ "type": "bearer",
+ "bound_domain": "api.spotify.com",
+ "expires_at": "2026-03-28T21:00:00Z"
+ },
+ "entries": [
+ {
+ "method": "GET",
+ "url": "https://api.spotify.com/v1/me/playlists",
+ "request_headers": {
+ "Authorization": "Bearer test-token-123",
+ "Content-Type": "application/json"
+ },
+ "request_body": "",
+ "response_status": 200,
+ "response_content_type": "application/json",
+ "response_body": "{\"items\":[{\"id\":\"abc\",\"name\":\"My Playlist\"}],\"total\":1}",
+ "classification": "api",
+ "is_noise": false
+ }
+ ]
+}
← 7df1537a docs(cli): fix Trevin article link in credits (#36)
·
back to Cli Printing Press
·
feat(websniff): add captured traffic test fixture generator 96bc65df →