Initial project scaffold: macOS package security audit tool
This commit establishes the foundation for a comprehensive security audit tool for macOS developer machines. It audits all installed software, detects vulnerabilities via OSV.dev, and provides Claude-powered mitigation recommendations. ## Architecture - System inventory collection (Homebrew, pip, npm, Go, Rust, apps) - CVE scanning via OSV.dev API (no auth, unlimited rate limits) - Claude integration (CLI or SDK) for mitigation analysis - JSON + HTML report generation - Full test suite (34+ tests, 7 benchmarks) - Production-grade linting (11 linters via golangci-lint) - Vulnerability scanning (govulncheck) - GitHub Actions CI/CD pipeline ## Key Components - cmd/audit/: CLI entry point - internal/inventory/: System inventory parsing - internal/security/: OSV.dev querying, Claude integration, audit logic - internal/report/: JSON and HTML report generation - scripts/collect-inventory.sh: Bash script for system enumeration ## MVP Features - Homebrew package scanning - Python/Node/Go/Rust ecosystem scanning - CVSS-based severity filtering - Claude-powered recommendations (update/replace/protect/monitor) - Makefile automation (test, lint, vulnerability checks) - Pre-commit hooks configuration ## Testing & Quality - 34+ unit tests with table-driven patterns - 7 benchmark tests for performance - 11 configured linters (staticcheck, gosec, revive, etc.) - Code coverage reporting - GitHub Actions CI (tests on Go 1.22 & 1.23) - Pre-commit hook framework ## Documentation - README.md: Full feature and usage documentation - QUICKSTART.md: 3-minute setup guide - TESTING.md: Testing and code quality guide - QA_SETUP.md: Analysis tooling reference - DATA_SOURCES_SPEC.md: Vulnerability data source documentation ## Data Sources - OSV.dev: Primary CVE database (1.8 day latency, no auth needed) - GitHub Advisories: Supplement for maintainer-created advisories - OpenSSF Scorecard: Trust signals (repo health/practices) - Homebrew Formulae API: Package metadata - CISA KEV: Active exploitation tracking ## Next Steps Phase 1 (MVP): Complete and tested ✓ Phase 2 (planned): Third-party app scanning, VirusTotal integration Phase 3 (planned): Historical monitoring, scheduled audits, webhooks Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadInventory(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
inventoryJSON string
|
||||
wantErr bool
|
||||
validate func(*Inventory) bool
|
||||
}{
|
||||
{
|
||||
name: "valid inventory",
|
||||
inventoryJSON: `{
|
||||
"timestamp": "2024-01-01T12:00:00Z",
|
||||
"system": {
|
||||
"os": "Darwin",
|
||||
"kernel_release": "23.0.0",
|
||||
"macos_version": "14.0",
|
||||
"hostname": "macbook",
|
||||
"username": "user"
|
||||
},
|
||||
"homebrew": [
|
||||
{"name": "curl", "version": "8.0.0", "source": "homebrew"}
|
||||
],
|
||||
"python": [],
|
||||
"node": [],
|
||||
"go": [],
|
||||
"applications": []
|
||||
}`,
|
||||
wantErr: false,
|
||||
validate: func(inv *Inventory) bool {
|
||||
return inv.System.OS == "Darwin" &&
|
||||
len(inv.Homebrew) == 1 &&
|
||||
inv.Homebrew[0].Name == "curl"
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty inventory",
|
||||
inventoryJSON: `{
|
||||
"timestamp": "2024-01-01T12:00:00Z",
|
||||
"system": {
|
||||
"os": "Darwin",
|
||||
"kernel_release": "23.0.0",
|
||||
"macos_version": "14.0",
|
||||
"hostname": "macbook",
|
||||
"username": "user"
|
||||
},
|
||||
"homebrew": [],
|
||||
"python": [],
|
||||
"node": [],
|
||||
"go": [],
|
||||
"applications": []
|
||||
}`,
|
||||
wantErr: false,
|
||||
validate: func(inv *Inventory) bool {
|
||||
return inv.CountPackages() == 0
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid JSON",
|
||||
inventoryJSON: `{invalid json}`,
|
||||
wantErr: true,
|
||||
validate: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create temporary file
|
||||
tmpFile := filepath.Join(t.TempDir(), "inventory.json")
|
||||
if err := os.WriteFile(tmpFile, []byte(tt.inventoryJSON), 0o644); err != nil {
|
||||
t.Fatalf("failed to create temp file: %v", err)
|
||||
}
|
||||
|
||||
inv, err := LoadInventory(tmpFile)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("LoadInventory() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if err == nil && tt.validate != nil {
|
||||
if !tt.validate(inv) {
|
||||
t.Errorf("LoadInventory() validation failed")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInventoryAllPackages(t *testing.T) {
|
||||
inv := &Inventory{
|
||||
Timestamp: time.Now(),
|
||||
System: SystemInfo{OS: "Darwin"},
|
||||
Homebrew: []Package{
|
||||
{Name: "curl", Version: "8.0", Source: "homebrew"},
|
||||
},
|
||||
Python: []Package{
|
||||
{Name: "requests", Version: "2.28.0", Source: "pip"},
|
||||
},
|
||||
Node: []Package{
|
||||
{Name: "lodash", Version: "4.17.0", Source: "npm"},
|
||||
},
|
||||
Go: []Package{},
|
||||
Applications: []Package{
|
||||
{Name: "Chrome", Version: "120.0", Source: "application"},
|
||||
},
|
||||
}
|
||||
|
||||
all := inv.AllPackages()
|
||||
if len(all) != 4 {
|
||||
t.Errorf("AllPackages() returned %d packages, want 4", len(all))
|
||||
}
|
||||
|
||||
names := make(map[string]bool)
|
||||
for _, pkg := range all {
|
||||
names[pkg.Name] = true
|
||||
}
|
||||
|
||||
expectedNames := []string{"curl", "requests", "lodash", "Chrome"}
|
||||
for _, name := range expectedNames {
|
||||
if !names[name] {
|
||||
t.Errorf("AllPackages() missing package: %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInventoryCountPackages(t *testing.T) {
|
||||
inv := &Inventory{
|
||||
Homebrew: make([]Package, 5),
|
||||
Python: make([]Package, 3),
|
||||
Node: make([]Package, 2),
|
||||
Go: make([]Package, 0),
|
||||
Applications: make([]Package, 10),
|
||||
}
|
||||
|
||||
if count := inv.CountPackages(); count != 20 {
|
||||
t.Errorf("CountPackages() = %d, want 20", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInventoryCountBySource(t *testing.T) {
|
||||
inv := &Inventory{
|
||||
Homebrew: []Package{
|
||||
{Name: "curl", Version: "8.0", Source: "homebrew"},
|
||||
{Name: "git", Version: "2.40", Source: "homebrew"},
|
||||
},
|
||||
Python: []Package{
|
||||
{Name: "requests", Version: "2.28.0", Source: "pip"},
|
||||
},
|
||||
Node: []Package{
|
||||
{Name: "lodash", Version: "4.17.0", Source: "npm"},
|
||||
},
|
||||
Go: []Package{},
|
||||
Applications: []Package{},
|
||||
}
|
||||
|
||||
counts := inv.CountBySource()
|
||||
|
||||
tests := []struct {
|
||||
source string
|
||||
want int
|
||||
}{
|
||||
{"homebrew", 2},
|
||||
{"pip", 1},
|
||||
{"npm", 1},
|
||||
{"go", 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := counts[tt.source]; got != tt.want {
|
||||
t.Errorf("CountBySource()[%s] = %d, want %d", tt.source, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackageMarshalJSON(t *testing.T) {
|
||||
pkg := Package{
|
||||
Name: "curl",
|
||||
Version: "8.0.0",
|
||||
Source: "homebrew",
|
||||
Path: "/usr/local/opt/curl",
|
||||
BundleID: "com.example.curl",
|
||||
}
|
||||
|
||||
// Should marshal without error
|
||||
data, err := json.Marshal(pkg)
|
||||
if err != nil {
|
||||
t.Errorf("Marshal() error = %v", err)
|
||||
}
|
||||
|
||||
// Should unmarshal back to same values
|
||||
var unmarshaled Package
|
||||
if err := json.Unmarshal(data, &unmarshaled); err != nil {
|
||||
t.Errorf("Unmarshal() error = %v", err)
|
||||
}
|
||||
|
||||
if unmarshaled != pkg {
|
||||
t.Errorf("Round-trip marshal/unmarshal failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemInfoFields(t *testing.T) {
|
||||
si := SystemInfo{
|
||||
OS: "Darwin",
|
||||
KernelRelease: "23.0.0",
|
||||
MacOSVersion: "14.0",
|
||||
Hostname: "macbook",
|
||||
Username: "testuser",
|
||||
}
|
||||
|
||||
data, err := json.Marshal(si)
|
||||
if err != nil {
|
||||
t.Errorf("Marshal() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify all fields are present in JSON
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
t.Errorf("Unmarshal() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
expectedFields := []string{"os", "kernel_release", "macos_version", "hostname", "username"}
|
||||
for _, field := range expectedFields {
|
||||
if _, ok := raw[field]; !ok {
|
||||
t.Errorf("Missing field in JSON: %s", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCountPackages(b *testing.B) {
|
||||
inv := &Inventory{
|
||||
Homebrew: make([]Package, 50),
|
||||
Python: make([]Package, 100),
|
||||
Node: make([]Package, 75),
|
||||
Go: make([]Package, 25),
|
||||
Applications: make([]Package, 200),
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = inv.CountPackages()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCountBySource(b *testing.B) {
|
||||
inv := &Inventory{
|
||||
Homebrew: make([]Package, 50),
|
||||
Python: make([]Package, 100),
|
||||
Node: make([]Package, 75),
|
||||
Go: make([]Package, 25),
|
||||
Applications: make([]Package, 200),
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = inv.CountBySource()
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadInventoryFileNotFound(t *testing.T) {
|
||||
_, err := LoadInventory("/nonexistent/path/inventory.json")
|
||||
if err == nil {
|
||||
t.Error("LoadInventory() should error for nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInventoryTimestamp(t *testing.T) {
|
||||
now := time.Now()
|
||||
inv := &Inventory{
|
||||
Timestamp: now,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(inv)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
|
||||
var unmarshaled Inventory
|
||||
if err := json.Unmarshal(data, &unmarshaled); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
|
||||
// Timestamps should be equal (within millisecond precision)
|
||||
if !inv.Timestamp.Equal(unmarshaled.Timestamp) {
|
||||
t.Errorf("Timestamp mismatch: %v != %v", inv.Timestamp, unmarshaled.Timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLargeInventory(t *testing.T) {
|
||||
// Test with realistic inventory size
|
||||
var packages []Package
|
||||
for i := 0; i < 1000; i++ {
|
||||
packages = append(packages, Package{
|
||||
Name: "pkg" + string(rune(i)),
|
||||
Version: "1.0.0",
|
||||
Source: "homebrew",
|
||||
})
|
||||
}
|
||||
|
||||
inv := &Inventory{
|
||||
Homebrew: packages,
|
||||
}
|
||||
|
||||
if count := inv.CountPackages(); count != 1000 {
|
||||
t.Errorf("Large inventory: CountPackages() = %d, want 1000", count)
|
||||
}
|
||||
|
||||
// Should be fast
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
if err := enc.Encode(inv); err != nil {
|
||||
t.Errorf("Large inventory encoding error: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user