Skip to content

🛡️ security_monitor — Security Monitoring

This example demonstrates how to build a continuously running security monitor based on the Packagist security advisory endpoint: pull all advisories, filter for packages of interest, persist results to timestamped JSON reports, and provide a cron-based scheduling approach.

🎯 Example Positioning

security_monitor is the 7th and most comprehensive example in the Packagist API Remote Operations series. It chains together the client initialization and advisory querying learned earlier into a small, production-ready tool.

  • 📚 What you'll learn: how to wrap the SDK's GetSecurityAdvisories inside a custom struct, combined with os / encoding/json / path/filepath to implement a full "pull → filter → persist → scheduled re-run" pipeline.
  • 🔗 Corresponding SDK method: client.ComposerClient.GetSecurityAdvisories.
  • 💡 Difference from security_advisories: security_advisories only demonstrates "one-off call + print"; this example productionizes it — adding a data directory, timestamped filenames, tracked-package filtering, and cron hints, marking the first step toward a production-grade monitor.
  • 🧱 Design highlights: the SecurityMonitor struct holds three pieces of state — the client, the data directory, and the tracked-packages list. The FetchAdvisories method exposes the "run one scan" semantics, making it easy for a scheduler to call repeatedly.

💻 Full Code

go
package main

import (
	"encoding/json"
	"fmt"
	"log"
	"os"
	"path/filepath"
	"time"

	"github.com/scagogogo/composer-skills/pkg/client"
	"github.com/scagogogo/composer-skills/pkg/domain"
)

// SecurityMonitor represents a security monitor
type SecurityMonitor struct {
	client          *client.ComposerClient
	dataDir         string
	trackedPackages []string
}

// NewSecurityMonitor creates a new security monitor
func NewSecurityMonitor(dataDir string, packages []string) *SecurityMonitor {
	return &SecurityMonitor{
		client:          client.NewComposerClient(30 * time.Second),
		dataDir:         dataDir,
		trackedPackages: packages,
	}
}

// FetchAdvisories fetches and saves security advisories
func (m *SecurityMonitor) FetchAdvisories() error {
	// Fetch all security advisories
	fmt.Println("Fetching all security advisories...")
	advisories, err := m.client.GetSecurityAdvisories()
	if err != nil {
		return fmt.Errorf("failed to fetch security advisories: %w", err)
	}

	// Ensure the data directory exists
	if err := os.MkdirAll(m.dataDir, 0755); err != nil {
		return fmt.Errorf("failed to create data directory: %w", err)
	}

	// Save the full advisory data
	timestamp := time.Now().Format("20060102-150405")
	fullDataPath := filepath.Join(m.dataDir, fmt.Sprintf("all_advisories_%s.json", timestamp))

	data, err := json.MarshalIndent(advisories, "", "  ")
	if err != nil {
		return fmt.Errorf("failed to serialize advisory data: %w", err)
	}
	if err := os.WriteFile(fullDataPath, data, 0644); err != nil {
		return fmt.Errorf("failed to save advisory data: %w", err)
	}
	fmt.Printf("Saved all advisory data to %s\n", fullDataPath)

	// Filter advisories for tracked packages
	trackedAdvisories := make(map[string][]*domain.Advisory)
	for _, pkgName := range m.trackedPackages {
		if advisories, ok := advisories.Advisories[pkgName]; ok {
			trackedAdvisories[pkgName] = advisories
			fmt.Printf("Found %d security advisories for %s\n", len(advisories), pkgName)
		}
	}

	// If tracked packages have advisories, save a separate report
	if len(trackedAdvisories) > 0 {
		trackedDataPath := filepath.Join(m.dataDir, fmt.Sprintf("tracked_advisories_%s.json", timestamp))

		data, err := json.MarshalIndent(trackedAdvisories, "", "  ")
		if err != nil {
			return fmt.Errorf("failed to serialize tracked-package advisory data: %w", err)
		}
		if err := os.WriteFile(trackedDataPath, data, 0644); err != nil {
			return fmt.Errorf("failed to save tracked-package advisory data: %w", err)
		}
		fmt.Printf("Saved tracked-package advisory data to %s\n", trackedDataPath)
	} else {
		fmt.Println("No security advisories found for tracked packages")
	}
	return nil
}

func main() {
	// List of packages to track
	trackedPackages := []string{
		"symfony/symfony",
		"laravel/framework",
		"guzzlehttp/guzzle",
		"monolog/monolog",
		"phpunit/phpunit",
	}

	// Create the security monitor
	monitor := NewSecurityMonitor("security_data", trackedPackages)

	// Fetch and save security advisories
	if err := monitor.FetchAdvisories(); err != nil {
		log.Fatalf("Failed to monitor security advisories: %v", err)
	}

	fmt.Println("\nSecurity monitoring complete. You can set this script to run periodically via a cron job for continuous monitoring of security advisories.")
	fmt.Println("Example cron expression (run once a day): 0 0 * * * /path/to/security_monitor")
}

🧩 Code Walkthrough

  • 🏗️ Encapsulating the monitor struct: SecurityMonitor collapses *client.ComposerClient, the data directory dataDir, and the tracked-packages list trackedPackages into a single object. This way, all state needed for "one scan round" is encapsulated, and external callers only need monitor.FetchAdvisories(), making it easy for cron to schedule repeatedly.
  • ⏱️ Client with timeout: client.NewComposerClient(30 * time.Second) injects an HTTP timeout at construction time, preventing the process from hanging for a long time when the advisory endpoint responds slowly. In production, tune this value upward based on data volume.
  • 🌐 One-shot full pull: m.client.GetSecurityAdvisories() hits /api/security-advisories/?updatedSince=0 and returns *domain.AdvisoriesResponse, whose Advisories field is map[packageName][]*Advisory, convenient for O(1) lookups by package name.
  • 📁 Idempotent directory creation: os.MkdirAll(m.dataDir, 0755) does not error when the directory already exists, ensuring consistent behavior between the first run and subsequent re-runs — no need to mkdir manually in advance.
  • 🕒 Timestamped filenames: time.Now().Format("20060102-150405") produces a YYYYMMDD-HHMMSS string, embedded into all_advisories_<ts>.json and tracked_advisories_<ts>.json. Each run produces a snapshot, so history is traceable and files don't overwrite each other.
  • 💾 Pretty-printed JSON: json.MarshalIndent(advisories, "", " ") serializes with two-space indentation; the resulting file can be read directly with jq or a text editor, convenient for manual inspection and diffing.
  • 🔍 Filter by tracked packages: iterate trackedPackages and look up each package name in the advisories.Advisories map — on a hit, collect it into trackedAdvisories and print the hit count. This is more efficient than a full traversal, since the tracked list is usually far smaller than the full advisory set.
  • 🧾 Two reports, separated: the full report serves as an audit record; the tracked report contains only the packages of interest — smaller in size, stronger in signal, and suitable for feeding directly into downstream alerting logic.
  • 🛡️ Error wrapping and interruption: each step wraps context with fmt.Errorf("...: %w", err) and ultimately aborts via log.Fatalf in main; if any step fails, it won't continue writing dirty data.
  • Cron-ification hint: the tail prints 0 0 * * * /path/to/security_monitor, upgrading "run once" to "run once a day" continuous monitoring — just drop the compiled binary into a crontab.

▶️ How to Run

Run directly from the example directory:

bash
cd /home/cc11001100/github/scagogogo/composer-skills/examples/security_monitor
go run main.go

You can also compile it into a binary and hand it to cron:

bash
go build -o security_monitor
./security_monitor

# Add to crontab to run once at midnight every day
# 0 0 * * * /path/to/security_monitor

⚠️ This example makes real Packagist API calls, and the full advisory data is large (MB-scale). Avoid running it at high frequency; when integrating with cron, once a day is recommended, and ensure the working directory has permission to write security_data/.

📚 SDK Methods Involved

Method NamePackageEndpointDoc Link
GetSecurityAdvisoriespkg/client (ComposerClient)GET /api/security-advisories/?updatedSince=0/sdk/packagist/methods/get-security-advisories
NewComposerClientpkg/clientConstructor (injects HTTP timeout)/sdk/packagist/methods/get-security-advisories

📝 Note: The core of this example calls only one SDK method, GetSecurityAdvisories. The domain.AdvisoriesResponse.Advisories it returns is map[string][]*domain.Advisory, so subsequent filtering, counting, and serialization are done with the standard library and domain types — no other SDK method needed. If you only need to query a handful of packages, use the more bandwidth-friendly GetSecurityAdvisoriesForPackages.

🚀 Going Further

  • ⏱️ Incremental pulling: swap GetSecurityAdvisories for GetSecurityAdvisoriesSince(lastRunTime) to pull only advisories updated since the last run, drastically reducing traffic and persisted volume; just record lastRunTime in a local file.
  • 🎯 Targeted queries: if the number of tracked packages is small, use GetSecurityAdvisoriesForPackages(trackedPackages) directly — the server filters by packages[], sparing the local full-set filter.
  • 📢 Hook up alerting channels: when trackedAdvisories is non-empty, format the hit *domain.Advisory entries (which include CVE IDs, affected versions, and fixed versions) and push them to a Slack/Feishu webhook or email to achieve "alert on discovery."
  • 🗃️ History comparison and deduplication: when scanning, read the previous tracked_advisories_*.json and flag new advisory IDs as "NEW" to avoid duplicate alerts; keep snapshots from the last N days and rotate them out automatically.
  • 🧾 Structured storage: persist *domain.Advisory into SQLite/PostgreSQL, index by package name, severity, and CVE, then build a security posture dashboard on the front end.
  • 🔁 More robust scheduling: replace cron with a systemd timer or Go's built-in time.Ticker, add failure retry and exponential backoff, and write a last_run.json (status + timestamp) at the end of each run for easier troubleshooting.

Released under the MIT License