Skip to content
Draft
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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,31 @@ echo "1.1.1.1" | ./zannotate --routing --routing-mrt-file=/tmp/rib.20250923.1200
{"ip":"1.1.1.1","routing":{"prefix":"1.1.1.0/24","asn":13335,"path":[3561,209,3356,13335]}}
```

### Spur IP Enrichment + Intelligence

[spur.us](https://spur.us/) provides per‑IP intelligence such as ASN and organization, infrastructure classification (e.g., datacenter, CDN, mobile), and geolocation metadata.
We can query [spur.us](https://spur.us/) alongside other sources to enrich annotations and help identify datacenter/Anycast deployments, CDNs, and ISP ownership.

0. You'll need an API key from Spur to enable ZAnnotate to enrich IPs with their dataset as we'll need to make an API request for each IP address to be enriched.
Depending on current pricing, you may need to sign up for a paid account.
Check out [spur.us/pricing](https://spur.us/pricing) for more details.
1. Once you have an API key, set it as an environment variable in your current shell session:

```shell
export SPUR_API_KEY=your_api_key_here
```
(If you want to make this permanent, add the above line to your shell profile, e.g. `~/.bashrc` or `~/.zshrc`)
2. Test with:

```shell
$ echo "1.1.1.1" | ./zannotate --spur
```

Example Output:
```shell
{"ip":"1.1.1.1","spur":{"as":{"number":13335,"organization":"Cloudflare, Inc."},"infrastructure":"DATACENTER","ip":"1.1.1.1","location":{"city":"Anycast","country":"ZZ","state":"Anycast"},"organization":"Taguchi Digital Marketing System"}}
```

# Input/Output

## Output
Expand Down
157 changes: 157 additions & 0 deletions spur.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/*
* ZAnnotate Copyright 2026 Regents of the University of Michigan
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
* implied. See the License for the specific language governing
* permissions and limitations under the License.
*/

package zannotate

import (
"bytes"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net"
"net/http"
"os"
"strings"
"time"

log "github.com/sirupsen/logrus"
)

const SpurApiUrl = "https://api.spur.us/v2/context/"

type SpurAnnotatorFactory struct {
BasePluginConf
apiKey string // Spur API Key, pulled from env var
timeoutSecs int
}

type SpurAnnotator struct {
Factory *SpurAnnotatorFactory
Id int
client *http.Client
}

// Spur Annotator Factory (Global)

func (a *SpurAnnotatorFactory) MakeAnnotator(i int) Annotator {
var v SpurAnnotator
v.Factory = a
v.Id = i
return &v
}

func (a *SpurAnnotatorFactory) Initialize(_ *GlobalConf) error {
// Check for API key
a.apiKey = os.Getenv("SPUR_API_KEY")
if len(a.apiKey) == 0 {
return errors.New("SPUR_API_KEY environment variable not set. Please use 'export SPUR_API_KEY=your_api_key' to set it")
}
return nil
}

func (a *SpurAnnotatorFactory) GetWorkers() int {
return a.Threads
}

func (a *SpurAnnotatorFactory) Close() error {
return nil
}

func (a *SpurAnnotatorFactory) IsEnabled() bool {
return a.Enabled
}

func (a *SpurAnnotatorFactory) AddFlags(flags *flag.FlagSet) {
flags.BoolVar(&a.Enabled, "spur", false, "enrich with Spur's threat intelligence data")
flags.IntVar(&a.Threads, "spur-threads", 100, "how many threads to use for Spur lookups")
flags.IntVar(&a.timeoutSecs, "spur-timeout", 2, "timeout for each Spur query, in seconds")
}

// Spur Annotator (Per-Worker)
func (a *SpurAnnotator) Initialize() error {
a.client = &http.Client{
Timeout: time.Duration(a.Factory.timeoutSecs) * time.Second,
}
return nil
}

func (a *SpurAnnotator) GetFieldName() string {
return "spur"
}

// Annotate performs a Spur data lookup for the given IP address and returns the results.
// If an error occurs or a lookup fails, it returns nil
func (a *SpurAnnotator) Annotate(ip net.IP) interface{} {
req, err := http.NewRequest(
http.MethodGet,
fmt.Sprintf("%s%s", SpurApiUrl, ip.String()),
nil,
)
if err != nil {
log.Errorf("failed to create Spur HTTP request for IP %s: %v", ip.String(), err)
return nil
}

req.Header.Set("Token", a.Factory.apiKey) // Set the API key in the request header

resp, err := a.client.Do(req)
if err != nil {
log.Errorf("http request to Spur API failed for IP %s: %v", ip.String(), err)
return nil
}
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
log.Errorf("failed to close Spur API response body: %v", err)
}
}(resp.Body)

body, err := io.ReadAll(resp.Body)
if err != nil {
log.Errorf("failed to read response body for IP %s: %v", ip.String(), err)
return nil
}
switch resp.StatusCode {
case http.StatusOK:
trimmed, _ := strings.CutSuffix(string(body), "\n") // Remove trailing newline if present, cleans up output
return json.RawMessage(trimmed)
case http.StatusUnauthorized:
// retrieve error message from body if possible
var msg string
if json.Valid(body) {
var compact bytes.Buffer
if err := json.Compact(&compact, body); err == nil {
msg = compact.String()
} else {
// fallback to raw trimmed text if compaction fails
msg = strings.TrimSpace(string(body))
}
}
// wouldn't be able to recover from an auth error, so log fatal with details and exit
log.Fatalf("error from Spur API for IP %s. Spur responded with %s: double-check your API key", ip.String(), msg)

}
return nil
}

func (a *SpurAnnotator) Close() error {
return nil
}

func init() {
s := new(SpurAnnotatorFactory)
RegisterAnnotator(s)
}
96 changes: 96 additions & 0 deletions spur_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
* ZAnnotate Copyright 2026 Regents of the University of Michigan
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
* implied. See the License for the specific language governing
* permissions and limitations under the License.
*/

package zannotate

import (
"encoding/json"
"io"
"net"
"net/http"
"strings"
"testing"
)

type mockRoundTripper struct {
expectedToken string
status int
body string
}

func (m mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
// Validate header was set (helps ensure Annotate sets Token)
if req.Header.Get("Token") != m.expectedToken {
return &http.Response{
StatusCode: http.StatusUnauthorized,
Body: io.NopCloser(strings.NewReader(`{"error":"missing token"}`)),
Header: make(http.Header),
}, nil
}

return &http.Response{
StatusCode: m.status,
Body: io.NopCloser(strings.NewReader(m.body)),
Header: make(http.Header),
}, nil
}

func TestSpurAnnotatorMockSuccess(t *testing.T) {
// Build factory and annotator
factory := &SpurAnnotatorFactory{apiKey: "test-key", timeoutSecs: 2}
a := factory.MakeAnnotator(0).(*SpurAnnotator)

// Inject a mock http.Client that returns a fixed successful JSON body
a.client = &http.Client{
Transport: mockRoundTripper{
expectedToken: "test-key",
status: http.StatusOK,
body: `{"as":{"number":13335,"organization":"Cloudflare, Inc."},"infrastructure":"DATACENTER","ip":"1.1.1.1","location":{"city":"Anycast","country":"ZZ","state":"Anycast"},"organization":"Taguchi Digital Marketing System"}`,
},
}

res := a.Annotate(net.ParseIP("1.1.1.1"))
if res == nil {
t.Fatalf("expected non-nil result")
}

raw, ok := res.(json.RawMessage)
if !ok {
t.Fatalf("expected json.RawMessage, got %T", res)
}

expected := `{"as":{"number":13335,"organization":"Cloudflare, Inc."},"infrastructure":"DATACENTER","ip":"1.1.1.1","location":{"city":"Anycast","country":"ZZ","state":"Anycast"},"organization":"Taguchi Digital Marketing System"}`
if string(raw) != expected {
t.Fatalf("unexpected JSON returned: got %s want %s", string(raw), expected)
}
}

func TestSpurAnnotatorMockNon200(t *testing.T) {
factory := &SpurAnnotatorFactory{apiKey: "test-key", timeoutSecs: 2}
a := factory.MakeAnnotator(0).(*SpurAnnotator)

a.client = &http.Client{
Transport: mockRoundTripper{
expectedToken: "test-key",
status: http.StatusInternalServerError,
body: `{"error":"server error"}`,
},
}

res := a.Annotate(net.ParseIP("1.1.1.1"))
if res != nil {
t.Fatalf("expected nil result for non-200 response, got %v", res)
}
// Test should return nil for a non-200 status
}
Loading