Skip to content
Open
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
146 changes: 146 additions & 0 deletions FUZZ_TEST.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# Fuzz Testing

## Overview

Console employs two complementary fuzzing strategies:

1. **Internal function fuzzing** — Go's built-in `go test -fuzz` framework targets parsing, transformation, validation, and cryptographic functions at the unit level.
2. **API fuzzing** — *(planned)* HTTP-level fuzzing of Console's REST API endpoints.

---

## Internal Function Fuzzing

Uses Go's native fuzzing to test internal functions for crash resistance, determinism, and input-safety. There are **17 fuzz targets** across **11 test files** covering four categories: JSON/DTO validation, use-case DTO↔entity transforms, cryptographic parsing, and string parsing.

### Coverage Summary

#### 1. JSON Deserialization & DTO Validation (7 targets)

All targets live in a single file and share a generic helper `fuzzJSONAndValidate[T]` that verifies `json.Unmarshal` determinism, `reflect.DeepEqual` consistency, and struct-level validator stability.

| Target | DTO Type | Validators Exercised | File |
|--------|----------|----------------------|------|
| `FuzzDeviceJSONProcessing` | `Device` | default | `internal/entity/dto/v1/json_fuzz_test.go` |
| `FuzzProfileJSONProcessing` | `Profile` | `ValidateAMTPassOrGenRan`, `ValidateCIRAOrTLS`, `ValidateWiFiDHCP` | `internal/entity/dto/v1/json_fuzz_test.go` |
| `FuzzDomainJSONProcessing` | `Domain` | `ValidateAlphaNumHyphenUnderscore` | `internal/entity/dto/v1/json_fuzz_test.go` |
| `FuzzCIRAConfigJSONProcessing` | `CIRAConfig` | default | `internal/entity/dto/v1/json_fuzz_test.go` |
| `FuzzWirelessConfigJSONProcessing` | `WirelessConfig` | `ValidateAuthandIEEE` | `internal/entity/dto/v1/json_fuzz_test.go` |
| `FuzzIEEE8021xJSONProcessing` | `IEEE8021xConfig` | `AuthProtocolValidator` | `internal/entity/dto/v1/json_fuzz_test.go` |
| `FuzzProfileWiFiJSONProcessing` | `ProfileWiFiConfigs` | default | `internal/entity/dto/v1/json_fuzz_test.go` |

**What is tested:** arbitrary JSON payloads (valid, malformed, deeply nested, oversized, unicode/null-byte) are deserialized twice and validated twice. Failures in determinism, panics, or mismatched validation results are caught.

#### 2. Use-Case DTO↔Entity Transform Round-Trips (7 targets)

Each target fuzzes both `dtoToEntity` and `entityToDTO` in its respective use-case package. Cryptographic dependencies are satisfied with mock implementations. All targets assert determinism via dual invocation and `reflect.DeepEqual`.

| Target | Package | Functions Under Test | File |
|--------|---------|----------------------|------|
| `FuzzCIRAConfigTransforms` | `ciraconfigs` | `dtoToEntity`, `entityToDTO` | `internal/usecase/ciraconfigs/transform_fuzz_test.go` |
| `FuzzDeviceTransforms` | `devices` | `dtoToEntity`, `entityToDTO` + GUID lowercasing, cert-hash nil check | `internal/usecase/devices/transform_fuzz_test.go` |
| `FuzzDomainTransforms` | `domains` | `dtoToEntity`, `entityToDTO` + RFC3339 expiration parsing, password encryption | `internal/usecase/domains/transform_fuzz_test.go` |
| `FuzzIEEE8021xConfigTransforms` | `ieee8021xconfigs` | `dtoToEntity`, `entityToDTO` | `internal/usecase/ieee8021xconfigs/transform_fuzz_test.go` |
| `FuzzProfileTransforms` | `profiles` | `dtoToEntity`, `entityToDTO` + tag join consistency | `internal/usecase/profiles/transform_fuzz_test.go` |
| `FuzzProfileWiFiConfigTransforms` | `profilewificonfigs` | `dtoToEntity`, `entityToDTO` | `internal/usecase/profilewificonfigs/transform_fuzz_test.go` |
| `FuzzWirelessConfigTransforms` | `wificonfigs` | `dtoToEntity`, `entityToDTO` + link-policy nil handling | `internal/usecase/wificonfigs/transform_fuzz_test.go` |

**What is tested:** fuzzed field values (strings, ints, bools, timestamps, oversized/unicode/null-byte data) are passed through transform functions. Panics, non-deterministic results, and invariant violations (e.g. GUID not lowercased, nil pointer where expected) are caught.

#### 3. Cryptographic Parsing (2 targets)

| Target | Package | Function Under Test | File |
|--------|---------|---------------------|------|
| `FuzzParseCertificateFromPEM` | `certificates` | `ParseCertificateFromPEM` | `internal/certificates/generate_fuzz_test.go` |
| `FuzzDecryptAndCheckCertExpiration` | `domains` | `DecryptAndCheckCertExpiration` | `internal/usecase/domains/usecase_fuzz_test.go` |

**What is tested:**
- `FuzzParseCertificateFromPEM`: valid, truncated, corrupted, swapped, and random PEM cert+key pairs. Asserts determinism, no data-alongside-error, no nil-without-error, and serial/key consistency.
- `FuzzDecryptAndCheckCertExpiration`: valid, expired, corrupted, and random base64-encoded PKCS12 blobs with varied passwords. Asserts determinism, no expired-cert-without-error, and byte-level certificate consistency.

#### 4. String Parsing (1 target)

| Target | Package | Function Under Test | File |
|--------|---------|---------------------|------|
| `FuzzParseInterval` | `devices` | `ParseInterval` (ISO 8601 duration → minutes) | `internal/usecase/devices/alarms_fuzz_test.go` |

**What is tested:** arbitrary strings (empty, malformed, oversized, unicode/control-character, and valid ISO 8601 durations) are parsed twice. Asserts determinism, no panics, and consistent error/result pairs across invocations.

---

### Seed Corpus Strategy

All targets use explicit `f.Add()` seeds covering:
- **Happy path:** well-formed, realistic inputs.
- **Empty/zero:** empty strings, zero ints, nil-equivalent bools.
- **Unicode & control characters:** CJK, emoji, null bytes (`\x00`), newlines.
- **Oversized inputs:** strings up to 4 KB, arrays with 4096 elements.
- **Type confusion:** wrong JSON types (number where string expected, etc.).
- **Boundary values:** `int` min/max, year-zero and year-9999 timestamps, port 65535.
- **Crypto edge cases:** truncated PEM, corrupted base64, swapped cert/key, wrong passwords, expired certificates.

---

### Running Internal Fuzz Tests

#### Prerequisites

```sh
cp .env.example .env # Makefile includes .env
```

#### Make Targets

```sh
# List all 17 fuzz targets
make fuzz-list

# Run a single target (recommended for local dev)
make fuzz-one PKG=./internal/usecase/devices TARGET=FuzzParseInterval FUZZTIME=30s

# Quick smoke: run every target once (seed corpus only)
make fuzz-smoke

# Full run: every target sequentially with time budget
make fuzz-all FUZZTIME=2m
```

#### Direct go test (single target)

```sh
go test ./internal/usecase/devices -run='^$' -fuzz='^FuzzParseInterval$' -fuzztime=30s
```

> **Note:** Go requires `-fuzz` to match exactly one fuzz function per package. Packages with multiple targets (e.g. `internal/entity/dto/v1` has 7) must be run one target at a time. The `make fuzz-all` target handles this automatically.

#### CI Usage

| Trigger | Command | Purpose |
|---------|---------|---------|
| Pull request | `make fuzz-smoke` | Replay seed corpus, catch regressions |
| Nightly/weekly schedule | `make fuzz-all FUZZTIME=2m` | Discover new crashes with mutation |

---

### File Index

| File | Targets | Package |
|------|---------|---------|
| `internal/certificates/generate_fuzz_test.go` | 1 | `certificates` |
| `internal/entity/dto/v1/json_fuzz_test.go` | 7 | `dto` |
| `internal/usecase/ciraconfigs/transform_fuzz_test.go` | 1 | `ciraconfigs` |
| `internal/usecase/devices/alarms_fuzz_test.go` | 1 | `devices` |
| `internal/usecase/devices/transform_fuzz_test.go` | 1 | `devices` |
| `internal/usecase/domains/transform_fuzz_test.go` | 1 | `domains` |
| `internal/usecase/domains/usecase_fuzz_test.go` | 1 | `domains` |
| `internal/usecase/ieee8021xconfigs/transform_fuzz_test.go` | 1 | `ieee8021xconfigs` |
| `internal/usecase/profiles/transform_fuzz_test.go` | 1 | `profiles` |
| `internal/usecase/profilewificonfigs/transform_fuzz_test.go` | 1 | `profilewificonfigs` |
| `internal/usecase/wificonfigs/transform_fuzz_test.go` | 1 | `wificonfigs` |
| **Total** | **17** | **9 packages** |

---

## API Fuzzing

*Planned — this section will document HTTP-level fuzz testing of Console's REST API endpoints.*
37 changes: 37 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,43 @@ test: ### run test
go test -v -cover -race ./...
.PHONY: test

FUZZ_ROOT ?= internal
FUZZTIME ?= 30s

fuzz-list: ### list all fuzz targets as '<package> <target>'
@set -eu; \
find $(FUZZ_ROOT) -name '*fuzz_test.go' -exec dirname {} \; | sort -u | while read -r dir; do \
pkg="./$$dir"; \
go test "$$pkg" -list '^Fuzz' 2>/dev/null | grep '^Fuzz' | while read -r target; do \
echo "$$pkg $$target"; \
done; \
done
.PHONY: fuzz-list

fuzz-one: ### run one fuzz target, e.g. make fuzz-one PKG=./internal/usecase/devices TARGET=FuzzParseInterval FUZZTIME=30s
@if [ -z "$(PKG)" ] || [ -z "$(TARGET)" ]; then \
echo "usage: make fuzz-one PKG=./path TARGET=FuzzTarget [FUZZTIME=30s]"; \
exit 1; \
fi
go test "$(PKG)" -run=^$$ -fuzz="^$(TARGET)$$" -fuzztime="$(FUZZTIME)"
.PHONY: fuzz-one

fuzz-smoke: ### run all fuzz targets once (quick CI smoke)
$(MAKE) fuzz-all FUZZTIME=1x
.PHONY: fuzz-smoke

fuzz-all: ### run all fuzz targets sequentially with FUZZTIME per target
@set -eu; \
find $(FUZZ_ROOT) -name '*fuzz_test.go' -exec dirname {} \; | sort -u | while read -r dir; do \
pkg="./$$dir"; \
targets=$$(go test "$$pkg" -list '^Fuzz' 2>/dev/null | grep '^Fuzz' || true); \
for target in $$targets; do \
echo "==> $$pkg $$target (FUZZTIME=$(FUZZTIME))"; \
go test "$$pkg" -run=^$$ -fuzz="^$${target}$$" -fuzztime="$(FUZZTIME)"; \
done; \
done
.PHONY: fuzz-all

integration-test: ### run integration-test
go clean -testcache && go test -v ./integration-test/...
.PHONY: integration-test
Expand Down
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,56 @@ Console automatically generates OpenAPI documentation when running in debug mode
- Windows: `docker run --rm -v ${pwd}:/app -w /app golangci/golangci-lint:latest golangci-lint run --config=./.golangci.yml -v`
- Unix: `docker run --rm -v .:/app -w /app golangci/golangci-lint:latest golangci-lint run --config=./.golangci.yml -v`

## Fuzz Testing

Console includes Go fuzz tests across multiple packages and targets. For full coverage details, seed corpus strategy, and CI guidance, see [FUZZ_TEST.md](FUZZ_TEST.md).

### Prerequisite

The Makefile includes `.env`, so ensure it exists before running make commands:

```sh
cp .env.example .env
```

### List all fuzz targets

```sh
make fuzz-list
```

Output format:

```text
./internal/usecase/devices FuzzParseInterval
./internal/entity/dto/v1 FuzzDeviceJSONProcessing
...
```

### Run a single fuzz target (recommended local workflow)

Go fuzzing runs one fuzz target at a time. Use `fuzz-one` for focused debugging and development:

```sh
make fuzz-one PKG=./internal/usecase/devices TARGET=FuzzParseInterval FUZZTIME=30s
```

### Run a quick all-target smoke pass

Runs each fuzz target once (`-fuzztime=1x`):

```sh
make fuzz-smoke
```

### Run all fuzz targets with time budget per target

```sh
make fuzz-all FUZZTIME=2m
```

This executes all discovered fuzz targets sequentially and is suitable for scheduled CI jobs.


## Additional Resources

Expand Down
150 changes: 150 additions & 0 deletions internal/certificates/generate_fuzz_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package certificates

import (
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
mathrand "math/rand"
"strings"
"testing"
"time"
)

func FuzzParseCertificateFromPEM(f *testing.F) {
validCertPEM, validKeyPEM := generateFuzzPEMCertificate(f, 1)
otherCertPEM, otherKeyPEM := generateFuzzPEMCertificate(f, 2)
truncatedCertPEM := validCertPEM[:len(validCertPEM)/2]
truncatedKeyPEM := validKeyPEM[:len(validKeyPEM)/2]
corruptedCertPEM := corruptPEMBody(validCertPEM)
corruptedKeyPEM := corruptPEMBody(validKeyPEM)

seedInputs := []struct {
certPEM string
keyPEM string
}{
{certPEM: validCertPEM, keyPEM: validKeyPEM},
{certPEM: otherCertPEM, keyPEM: otherKeyPEM},
{certPEM: validCertPEM, keyPEM: otherKeyPEM},
{certPEM: truncatedCertPEM, keyPEM: validKeyPEM},
{certPEM: validCertPEM, keyPEM: truncatedKeyPEM},
{certPEM: corruptedCertPEM, keyPEM: validKeyPEM},
{certPEM: validCertPEM, keyPEM: corruptedKeyPEM},
{certPEM: "", keyPEM: ""},
{certPEM: "invalid-pem", keyPEM: validKeyPEM},
{certPEM: invalidBase64PEM("CERTIFICATE"), keyPEM: validKeyPEM},
{certPEM: validCertPEM, keyPEM: "invalid-key"},
{certPEM: validCertPEM, keyPEM: invalidBase64PEM("RSA PRIVATE KEY")},
{certPEM: validKeyPEM, keyPEM: validCertPEM},
{certPEM: "前置\n" + validCertPEM + "後置", keyPEM: "🔐\n" + validKeyPEM},
{certPEM: strings.Repeat("A", 256), keyPEM: strings.Repeat("B", 256)},
}

for _, input := range seedInputs {
f.Add(input.certPEM, input.keyPEM)
}

f.Fuzz(func(t *testing.T, certPEM, keyPEM string) {
firstCert, firstKey, firstErr := ParseCertificateFromPEM(certPEM, keyPEM)
secondCert, secondKey, secondErr := ParseCertificateFromPEM(certPEM, keyPEM)

if (firstErr == nil) != (secondErr == nil) {
t.Fatalf("ParseCertificateFromPEM error mismatch for cert len=%d key len=%d: first=%v second=%v", len(certPEM), len(keyPEM), firstErr, secondErr)
}

if firstErr != nil {
verifyParseCertError(t, certPEM, keyPEM, firstErr, secondErr, firstCert, secondCert, firstKey, secondKey)

return
}

verifyParseCertSuccess(t, certPEM, keyPEM, firstCert, secondCert, firstKey, secondKey)
})
}

func verifyParseCertError(t *testing.T, certPEM, keyPEM string, firstErr, secondErr error, firstCert, secondCert *x509.Certificate, firstKey, secondKey *rsa.PrivateKey) {
t.Helper()

if firstErr.Error() != secondErr.Error() {
t.Fatalf("ParseCertificateFromPEM error text mismatch for cert len=%d key len=%d: first=%q second=%q", len(certPEM), len(keyPEM), firstErr.Error(), secondErr.Error())
}

if firstCert != nil || secondCert != nil || firstKey != nil || secondKey != nil {
t.Fatalf("ParseCertificateFromPEM returned parsed data alongside an error for cert len=%d key len=%d", len(certPEM), len(keyPEM))
}
}

func verifyParseCertSuccess(t *testing.T, certPEM, keyPEM string, firstCert, secondCert *x509.Certificate, firstKey, secondKey *rsa.PrivateKey) {
t.Helper()

if firstCert == nil || secondCert == nil || firstKey == nil || secondKey == nil {
t.Fatalf("ParseCertificateFromPEM returned nil data without an error for cert len=%d key len=%d", len(certPEM), len(keyPEM))
}

if firstCert.SerialNumber.Cmp(secondCert.SerialNumber) != 0 {
t.Fatalf("ParseCertificateFromPEM serial number mismatch for cert len=%d key len=%d", len(certPEM), len(keyPEM))
}

if firstCert.Subject.CommonName != secondCert.Subject.CommonName {
t.Fatalf("ParseCertificateFromPEM common name mismatch for cert len=%d key len=%d: first=%q second=%q", len(certPEM), len(keyPEM), firstCert.Subject.CommonName, secondCert.Subject.CommonName)
}

if firstKey.E != secondKey.E || firstKey.N.Cmp(secondKey.N) != 0 || firstKey.D.Cmp(secondKey.D) != 0 {
t.Fatalf("ParseCertificateFromPEM returned different private keys for cert len=%d key len=%d", len(certPEM), len(keyPEM))
}
}

func invalidBase64PEM(blockType string) string {
return "-----BEGIN " + blockType + "-----\n!!!!\n-----END " + blockType + "-----\n"
}

func corruptPEMBody(input string) string {
block, _ := pem.Decode([]byte(input))
if block == nil || len(block.Bytes) == 0 {
return input
}

corrupted := append([]byte(nil), block.Bytes...)
index := len(corrupted) / 2
corrupted[index] ^= 0xff

return string(pem.EncodeToMemory(&pem.Block{Type: block.Type, Bytes: corrupted}))
}

func generateFuzzPEMCertificate(tb testing.TB, seed int64) (certPEM, keyPEM string) {
tb.Helper()

rng := mathrand.New(mathrand.NewSource(seed))

privateKey, err := rsa.GenerateKey(rng, 1024)
if err != nil {
tb.Fatalf("failed to generate RSA key: %v", err)
}

serialNumber := new(big.Int).SetInt64(rng.Int63())

fixedTime := time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC)

template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
CommonName: "fuzz-cert",
Organization: []string{"console"},
},
NotBefore: fixedTime.Add(-time.Hour),
NotAfter: fixedTime.Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
}

certBytes, err := x509.CreateCertificate(rng, &template, &template, &privateKey.PublicKey, privateKey)
if err != nil {
tb.Fatalf("failed to create certificate: %v", err)
}

certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes}))
keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}))

return certPEM, keyPEM
}
Loading
Loading