From 8e37bbce183facfe61beb26ae770337c489bd324 Mon Sep 17 00:00:00 2001 From: phillip-stephens Date: Thu, 29 Jan 2026 16:28:08 -0800 Subject: [PATCH 1/7] added Spur integration --- spur.go | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 spur.go diff --git a/spur.go b/spur.go new file mode 100644 index 0000000..1146d06 --- /dev/null +++ b/spur.go @@ -0,0 +1,138 @@ +/* + * 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" + "errors" + "flag" + "fmt" + "io" + "net" + "net/http" + "os" + "strings" + "time" + + log "github.com/sirupsen/logrus" +) + +const SPUR_API_URL = "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", SPUR_API_URL, 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 resp.Body.Close() + + 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 + } + if resp.StatusCode == http.StatusOK { + trimmed, _ := strings.CutSuffix(string(body), "\n") // Remove trailing newline if present, cleans up output + return json.RawMessage(trimmed) + } + + log.Errorf("Spur API returned non-200 status for IP %s: %d - %s", ip.String(), resp.StatusCode, string(body)) + + return nil +} + +func (a *SpurAnnotator) Close() error { + return nil +} + +func init() { + s := new(SpurAnnotatorFactory) + RegisterAnnotator(s) +} From 933986f3f81a06719a98cc6bcf2bb1e3fded75d4 Mon Sep 17 00:00:00 2001 From: phillip-stephens Date: Wed, 18 Feb 2026 10:22:04 -0800 Subject: [PATCH 2/7] added Spur usage and description to the readme --- README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/README.md b/README.md index 2722a29..4492710 100644 --- a/README.md +++ b/README.md @@ -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 From fe4dda309281e289aa2d101869a7dc9e93a6696e Mon Sep 17 00:00:00 2001 From: phillip-stephens Date: Wed, 18 Feb 2026 10:45:56 -0800 Subject: [PATCH 3/7] added auth errors in case the API key fails for spur api --- spur.go | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/spur.go b/spur.go index 1146d06..62764a7 100644 --- a/spur.go +++ b/spur.go @@ -15,6 +15,7 @@ package zannotate import ( + "bytes" "encoding/json" "errors" "flag" @@ -121,10 +122,22 @@ func (a *SpurAnnotator) Annotate(ip net.IP) interface{} { if resp.StatusCode == http.StatusOK { trimmed, _ := strings.CutSuffix(string(body), "\n") // Remove trailing newline if present, cleans up output return json.RawMessage(trimmed) + } else if resp.StatusCode == 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) } - log.Errorf("Spur API returned non-200 status for IP %s: %d - %s", ip.String(), resp.StatusCode, string(body)) - return nil } From de20e16ba6f246da460d1a902403496b3d094c70 Mon Sep 17 00:00:00 2001 From: phillip-stephens Date: Wed, 18 Feb 2026 10:46:21 -0800 Subject: [PATCH 4/7] added the smokiest of smoke tests for spur integration --- spur_test.go | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 spur_test.go diff --git a/spur_test.go b/spur_test.go new file mode 100644 index 0000000..641a50a --- /dev/null +++ b/spur_test.go @@ -0,0 +1,82 @@ +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 +} From cb48bcd9efd70761f63865f51bb73171617a2c6a Mon Sep 17 00:00:00 2001 From: phillip-stephens Date: Wed, 18 Feb 2026 16:57:20 -0800 Subject: [PATCH 5/7] formatting for constant --- spur.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spur.go b/spur.go index 62764a7..a296a6e 100644 --- a/spur.go +++ b/spur.go @@ -30,7 +30,7 @@ import ( log "github.com/sirupsen/logrus" ) -const SPUR_API_URL = "https://api.spur.us/v2/context/" +const SpurApiUrl = "https://api.spur.us/v2/context/" type SpurAnnotatorFactory struct { BasePluginConf @@ -97,7 +97,7 @@ func (a *SpurAnnotator) GetFieldName() string { func (a *SpurAnnotator) Annotate(ip net.IP) interface{} { req, err := http.NewRequest( http.MethodGet, - fmt.Sprintf("%s%s", SPUR_API_URL, ip.String()), + fmt.Sprintf("%s%s", SpurApiUrl, ip.String()), nil, ) if err != nil { From 50b8059c56bae455baf25862e2a705b9ea90891e Mon Sep 17 00:00:00 2001 From: phillip-stephens Date: Wed, 18 Feb 2026 16:57:28 -0800 Subject: [PATCH 6/7] add license --- spur_test.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/spur_test.go b/spur_test.go index 641a50a..a634772 100644 --- a/spur_test.go +++ b/spur_test.go @@ -1,3 +1,17 @@ +/* + * 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 ( From 9ca575924583a6517f172cd2e86bbeb7a4593ee4 Mon Sep 17 00:00:00 2001 From: phillip-stephens Date: Wed, 18 Feb 2026 16:59:27 -0800 Subject: [PATCH 7/7] fmt and lint --- spur.go | 14 ++++++++++---- spur_test.go | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/spur.go b/spur.go index a296a6e..f977734 100644 --- a/spur.go +++ b/spur.go @@ -112,17 +112,23 @@ func (a *SpurAnnotator) Annotate(ip net.IP) interface{} { log.Errorf("http request to Spur API failed for IP %s: %v", ip.String(), err) return nil } - defer resp.Body.Close() + 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 } - if resp.StatusCode == http.StatusOK { + switch resp.StatusCode { + case http.StatusOK: trimmed, _ := strings.CutSuffix(string(body), "\n") // Remove trailing newline if present, cleans up output return json.RawMessage(trimmed) - } else if resp.StatusCode == http.StatusUnauthorized { + case http.StatusUnauthorized: // retrieve error message from body if possible var msg string if json.Valid(body) { @@ -136,8 +142,8 @@ func (a *SpurAnnotator) Annotate(ip net.IP) interface{} { } // 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 } diff --git a/spur_test.go b/spur_test.go index a634772..33319a7 100644 --- a/spur_test.go +++ b/spur_test.go @@ -56,7 +56,7 @@ func TestSpurAnnotatorMockSuccess(t *testing.T) { 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"}`, + 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"}`, }, }