feat(monitor/collectors/ransomware): ransomware.live client with stable triple-hash ID

Hand-crafted fixture matches spec §8.5 schema. Live API endpoint may move; client
takes BaseURL config for swap-in once api.ransomware.live publishes a stable JSON
endpoint (current free tier returns HTML landing page on /recentvictims).
This commit is contained in:
CarterPerez-dev 2026-05-01 22:47:19 -04:00
parent 01a22dc7e4
commit 3d71fd8c84
3 changed files with 142 additions and 0 deletions

View File

@ -0,0 +1,72 @@
// ©AngelaMos | 2026
// client.go
package ransomware
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
"golang.org/x/time/rate"
"github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx"
)
const (
defaultRansomBaseURL = "https://api.ransomware.live"
pathRecentVictims = "/recentvictims"
defaultRansomRate = time.Second
defaultRansomBudget = 5
defaultRansomBreakerWin = 60 * time.Second
idHashBytes = 16
)
type ClientConfig struct {
BaseURL string
}
type Client struct {
hx *httpx.Client
}
func NewClient(cfg ClientConfig) *Client {
if cfg.BaseURL == "" {
cfg.BaseURL = defaultRansomBaseURL
}
return &Client{
hx: httpx.New(httpx.Config{
Name: "ransomware",
BaseURL: cfg.BaseURL,
Rate: rate.Every(defaultRansomRate),
Burst: 1,
ConsecutiveFailureBudget: defaultRansomBudget,
BreakerTimeout: defaultRansomBreakerWin,
}),
}
}
type Victim struct {
PostTitle string `json:"post_title"`
GroupName string `json:"group_name"`
Discovered time.Time `json:"discovered"`
Country string `json:"country"`
Activity string `json:"activity"`
Website string `json:"website"`
Description string `json:"description"`
}
func (v Victim) ID() string {
h := sha256.Sum256([]byte(v.PostTitle + "|" + v.GroupName + "|" + v.Discovered.UTC().Format(time.RFC3339)))
return hex.EncodeToString(h[:idHashBytes])
}
func (c *Client) FetchRecent(ctx context.Context) ([]Victim, error) {
var out []Victim
if err := c.hx.GetJSON(ctx, pathRecentVictims, nil, &out); err != nil {
return nil, fmt.Errorf("fetch recentvictims: %w", err)
}
return out, nil
}

View File

@ -0,0 +1,44 @@
// ©AngelaMos | 2026
// client_test.go
package ransomware_test
import (
"context"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/ransomware"
)
func TestClient_FetchRecentVictimsDecodes(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
body, err := os.ReadFile("testdata/recentvictims.json")
require.NoError(t, err)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
}))
defer srv.Close()
c := ransomware.NewClient(ransomware.ClientConfig{BaseURL: srv.URL})
vs, err := c.FetchRecent(context.Background())
require.NoError(t, err)
require.Len(t, vs, 3)
require.Equal(t, "Acme Healthcare Corp", vs[0].PostTitle)
require.Equal(t, "lockbit", vs[0].GroupName)
}
func TestVictim_IDIsStableAndCollisionFree(t *testing.T) {
d, _ := time.Parse(time.RFC3339, "2026-05-01T00:00:00Z")
v1 := ransomware.Victim{PostTitle: "X", GroupName: "lockbit", Discovered: d}
v2 := ransomware.Victim{PostTitle: "X", GroupName: "lockbit", Discovered: d}
v3 := ransomware.Victim{PostTitle: "Y", GroupName: "lockbit", Discovered: d}
require.Equal(t, v1.ID(), v2.ID())
require.NotEqual(t, v1.ID(), v3.ID())
}

View File

@ -0,0 +1,26 @@
[
{
"post_title": "Acme Healthcare Corp",
"group_name": "lockbit",
"discovered": "2026-05-01T14:32:00Z",
"country": "US",
"activity": "Healthcare",
"website": "acme-health.example.com",
"description": "Patient records claimed leaked"
},
{
"post_title": "Northern Steel Manufacturing",
"group_name": "blackcat",
"discovered": "2026-05-01T11:08:00Z",
"country": "DE",
"activity": "Manufacturing",
"website": "northern-steel.example.de"
},
{
"post_title": "Banco Nacional",
"group_name": "play",
"discovered": "2026-04-30T22:15:00Z",
"country": "AR",
"activity": "Finance"
}
]