Repository: prebid/prebid-server
Pull request #1146 (2019-12-15 13:52:56)

Gamoshi Adapter: Update cookie sync
## Type of change
- [X] Maintenance

## Changes description

Update default configuration for cookie sync

SyntaxNode: A few questions:
1) This also updates the auction/bid url. That isn't mentioned in the PR description and I just want to verify it's intentional.
2) While we're here, does the Gamoshi cookie sync url accept a CCPA us_privacy value? That's available now with a `{{.USPrivacy}}` macro.
mmoses: Hi thanks.

Referring to your questions
1. Yes, The bid url and the cookie syncing are both international.
2. Great thinking, we are planing to add it in the next version, but we can
add it now to our url, do you want us to do it or you can add it ?

Best
Moses


On Mon, Dec 16, 2019 at 5:55 AM Scott Kay <notifications@github.com> wrote:

> A few questions:
>
>    1. This also updates the auction/bid url. That isn't mentioned in the
>    PR description and I just want to verify it's intentional.
>    2. While we're here, does the Gamoshi cookie sync url accept a CCPA
>    us_privacy value? That's available now with a {{.USPrivacy}} macro.
>
> —
> You are receiving this because you are subscribed to this thread.
> Reply to this email directly, view it on GitHub
> <https://github.com/prebid/prebid-server/pull/1146?email_source=notifications&email_token=AADASLTQQOBZGVRGJUCAIQLQY334BA5CNFSM4J3AG4NKYY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOEG5N72I#issuecomment-565895145>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AADASLXRKUWGDVRKMVDFN6TQY334BANCNFSM4J3AG4NA>
> .
>


Patch:
diff --git a/adapters/gamoshi/gamoshi.go b/adapters/gamoshi/gamoshi.go
index 016feb17..af915f68 100644
--- a/adapters/gamoshi/gamoshi.go
+++ b/adapters/gamoshi/gamoshi.go
@@ -101,7 +101,7 @@ func (a *GamoshiAdapter) MakeRequests(request *openrtb.BidRequest, reqInfo *adap
 	if len(thisURI) == 0 {
 		thisURI = "https://rtb.gamoshi.io"
 	}
-	thisURI = thisURI + "/r/" + gamoshiExt.SupplyPartnerId + "/bidr?reqformat=RTB_JSON"
+	thisURI = thisURI + "/r/" + gamoshiExt.SupplyPartnerId + "/bidr?bidder=prebid-server"
 	headers := http.Header{}
 	headers.Add("Content-Type", "application/json;charset=utf-8")
 	headers.Add("Accept", "application/json")
diff --git a/adapters/syncer.go b/adapters/syncer.go
index d86e3239..c212a436 100644
--- a/adapters/syncer.go
+++ b/adapters/syncer.go
@@ -46,7 +46,7 @@ func (s *Syncer) GetUsersyncInfo(privacyPolicies privacy.Policies) (*usersync.Us
 	syncURL, err := macros.ResolveMacros(*s.urlTemplate, macros.UserSyncTemplateParams{
 		GDPR:        privacyPolicies.GDPR.Signal,
 		GDPRConsent: privacyPolicies.GDPR.Consent,
-		USPrivacy:   privacyPolicies.CCPA.Signal,
+		USPrivacy:   privacyPolicies.CCPA.Value,
 	})
 	if err != nil {
 		return nil, err
diff --git a/adapters/triplelift_native/triplelift_native.go b/adapters/triplelift_native/triplelift_native.go
index a2576032..c3cb6b24 100644
--- a/adapters/triplelift_native/triplelift_native.go
+++ b/adapters/triplelift_native/triplelift_native.go
@@ -28,8 +28,6 @@ type TripleliftNativeExtInfo struct {
 	// Array is used for deserialization.
 	PublisherWhitelist []string `json:"publisher_whitelist"`
 
-	Endpoint string `json:"endpoint"`
-
 	// Map is used for optimized memory access and should be constructed after deserialization.
 	PublisherWhitelistMap map[string]bool
 }
@@ -198,5 +196,5 @@ func NewTripleliftNativeBidder(client *http.Client, endpoint string, extraInfo s
 
 	return &TripleliftNativeAdapter{
 		extInfo:  extInfo,
-		endpoint: extInfo.Endpoint}
+		endpoint: endpoint}
 }
diff --git a/config/config.go b/config/config.go
index 02b0415f..1b24f715 100644
--- a/config/config.go
+++ b/config/config.go
@@ -47,6 +47,7 @@ type Configuration struct {
 	Analytics            Analytics          `mapstructure:"analytics"`
 	AMPTimeoutAdjustment int64              `mapstructure:"amp_timeout_adjustment_ms"`
 	GDPR                 GDPR               `mapstructure:"gdpr"`
+	CCPA                 CCPA               `mapstructure:"ccpa"`
 	CurrencyConverter    CurrencyConverter  `mapstructure:"currency_converter"`
 	DefReqConfig         DefReqConfig       `mapstructure:"default_request"`
 
@@ -160,6 +161,10 @@ func (t *GDPRTimeouts) ActiveTimeout() time.Duration {
 	return time.Duration(t.ActiveVendorlistFetch) * time.Millisecond
 }
 
+type CCPA struct {
+	Enforce bool `mapstructure:"enforce"`
+}
+
 type Analytics struct {
 	File FileLogs `mapstructure:"file"`
 }
@@ -202,6 +207,7 @@ const (
 	dummyPublisherID string = "12"
 	dummyGDPR        string = "0"
 	dummyGDPRConsent string = "someGDPRConsentString"
+	dummyCCPA        string = "1NYN"
 )
 
 type Adapter struct {
@@ -216,8 +222,9 @@ type Adapter struct {
 	//
 	// This value will be interpreted as a Golang Template. At runtime, the following Template variables will be replaced.
 	//
-	//   {{.GDPR}} -- This will be replaced with the "gdpr" property sent to /cookie_sync
-	//   {{.Consent}} -- This will be replaced with the "consent" property sent to /cookie_sync
+	//   {{.GDPR}}      -- This will be replaced with the "gdpr" property sent to /cookie_sync
+	//   {{.Consent}}   -- This will be replaced with the "consent" property sent to /cookie_sync
+	//   {{.USPrivacy}} -- This will be replaced with the "us_privacy" property sent to /cookie_sync
 	//
 	// For more info on templates, see: https://golang.org/pkg/text/template/
 	UserSyncURL string `mapstructure:"usersync_url"`
@@ -275,7 +282,12 @@ func validateAdapterUserSyncURL(userSyncURL string, adapterName string, errs con
 			return append(errs, fmt.Errorf("Invalid user sync URL template: %s for adapter: %s. %v", userSyncURL, adapterName, err))
 		}
 		// Resolve macros (if any) in the user_sync URL
-		resolvedUserSyncURL, err := macros.ResolveMacros(*userSyncTemplate, macros.UserSyncTemplateParams{GDPR: dummyGDPR, GDPRConsent: dummyGDPRConsent})
+		dummyMacroValues := macros.UserSyncTemplateParams{
+			GDPR:        dummyGDPR,
+			GDPRConsent: dummyGDPRConsent,
+			USPrivacy:   dummyCCPA,
+		}
+		resolvedUserSyncURL, err := macros.ResolveMacros(*userSyncTemplate, dummyMacroValues)
 		if err != nil {
 			return append(errs, fmt.Errorf("Unable to resolve user sync URL: %s for adapter: %s. %v", userSyncURL, adapterName, err))
 		}
@@ -490,7 +502,7 @@ func (cfg *Configuration) setDerivedDefaults() {
 	setDefaultUsersync(cfg.Adapters, openrtb_ext.BidderEPlanning, "https://ads.us.e-planning.net/uspd/1/?du=https%3A%2F%2Fads.us.e-planning.net%2Fgetuid%2F1%2F5a1ad71d2d53a0f5%3F"+url.QueryEscape(externalURL)+"%2Fsetuid%3Fbidder%3Deplanning%26gdpr%3D{{.GDPR}}%26gdpr_consent%3D{{.GDPRConsent}}%26uid%3D%24UID")
 	// openrtb_ext.BidderFacebook doesn't have a good default.
 	// openrtb_ext.BidderGamma doesn't have a good default.
-	setDefaultUsersync(cfg.Adapters, openrtb_ext.BidderGamoshi, "https://rtb.gamoshi.io/pix/0000/scm?gdpr={{.GDPR}}&consent={{.GDPRConsent}}&rurl="+url.QueryEscape(externalURL)+"%2Fsetuid%3Fbidder%3Dgamoshi%26gdpr%3D{{.GDPR}}%26gdpr_consent%3D{{.GDPRConsent}}%26uid%3D%5Bgusr%5D")
+	setDefaultUsersync(cfg.Adapters, openrtb_ext.BidderGamoshi, "https://rtb.gamoshi.io/user_sync_prebid?gdpr={{.GDPR}}&consent={{.GDPRConsent}}&rurl="+url.QueryEscape(externalURL)+"%2Fsetuid%3Fbidder%3Dgamoshi%26gdpr%3D{{.GDPR}}%26gdpr_consent%3D{{.GDPRConsent}}%26uid%3D%5Bgusr%5D")
 	setDefaultUsersync(cfg.Adapters, openrtb_ext.BidderGrid, "https://x.bidswitch.net/check_uuid/"+url.QueryEscape(externalURL)+"%2Fsetuid%3Fbidder%3Dgrid%26gdpr%3D{{.GDPR}}%26gdpr_consent%3D{{.GDPRConsent}}%26uid%3D%24%7BBSW_UUID%7D?gdpr={{.GDPR}}&gdpr_consent={{.GDPRConsent}}")
 	setDefaultUsersync(cfg.Adapters, openrtb_ext.BidderGumGum, "https://rtb.gumgum.com/usync/prbds2s?gdpr={{.GDPR}}&gdpr_consent={{.GDPRConsent}}&r="+url.QueryEscape(externalURL)+"%2Fsetuid%3Fbidder%3Dgumgum%26gdpr%3D{{.GDPR}}%26gdpr_consent%3D{{.GDPRConsent}}%26uid%3D")
 	setDefaultUsersync(cfg.Adapters, openrtb_ext.BidderImprovedigital, "https://ad.360yield.com/server_match?gdpr={{.GDPR}}&gdpr_consent={{.GDPRConsent}}&r="+url.QueryEscape(externalURL)+"%2Fsetuid%3Fbidder%3Dimprovedigital%26gdpr%3D{{.GDPR}}%26gdpr_consent%3D{{.GDPRConsent}}%26uid%3D%7BPUB_USER_ID%7D")
@@ -707,6 +719,7 @@ func SetupViper(v *viper.Viper, filename string) {
 	v.SetDefault("gdpr.timeouts_ms.init_vendorlist_fetches", 0)
 	v.SetDefault("gdpr.timeouts_ms.active_vendorlist_fetch", 0)
 	v.SetDefault("gdpr.non_standard_publishers", []string{""})
+	v.SetDefault("ccpa.enforce", false)
 	v.SetDefault("currency_converter.fetch_url", "https://cdn.jsdelivr.net/gh/prebid/currency-file@1/latest.json")
 	v.SetDefault("currency_converter.fetch_interval_seconds", 1800) // fetch currency rates every 30 minutes
 	v.SetDefault("default_request.type", "")
diff --git a/endpoints/cookie_sync.go b/endpoints/cookie_sync.go
index 475416e8..9787a8f7 100644
--- a/endpoints/cookie_sync.go
+++ b/endpoints/cookie_sync.go
@@ -32,6 +32,7 @@ func NewCookieSyncEndpoint(syncers map[openrtb_ext.BidderName]usersync.Usersynce
 		syncPermissions: syncPermissions,
 		metrics:         metrics,
 		pbsAnalytics:    pbsAnalytics,
+		enforceCCPA:     cfg.CCPA.Enforce,
 	}
 	return deps.Endpoint
 }
@@ -43,6 +44,7 @@ type cookieSyncDeps struct {
 	syncPermissions gdpr.Permissions
 	metrics         pbsmetrics.MetricsEngine
 	pbsAnalytics    analytics.PBSAnalyticsModule
+	enforceCCPA     bool
 }
 
 func (deps *cookieSyncDeps) Endpoint(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
@@ -103,15 +105,26 @@ func (deps *cookieSyncDeps) Endpoint(w http.ResponseWriter, r *http.Request, _ h
 		}
 	}
 
+	privacyPolicy := privacy.Policies{
+		GDPR: gdprPolicy.Policy{
+			Signal:  gdprToString(parsedReq.GDPR),
+			Consent: parsedReq.Consent,
+		},
+		CCPA: ccpa.Policy{
+			Value: parsedReq.USPrivacy,
+		},
+	}
+
 	parsedReq.filterExistingSyncs(deps.syncers, userSyncCookie, needSyncupForSameSite)
+
 	adapterSyncs := make(map[openrtb_ext.BidderName]bool)
+	// assume all bidders will be privacy blocked
 	for _, b := range parsedReq.Bidders {
-		// assume all bidders will be GDPR blocked
 		adapterSyncs[openrtb_ext.BidderName(b)] = true
 	}
-	parsedReq.filterForGDPR(deps.syncPermissions)
+	parsedReq.filterForPrivacy(deps.syncPermissions, privacyPolicy, deps.enforceCCPA)
+	// surviving bidders are not privacy blocked
 	for _, b := range parsedReq.Bidders {
-		// surviving bidders are not GDPR blocked
 		adapterSyncs[openrtb_ext.BidderName(b)] = false
 	}
 	for b, g := range adapterSyncs {
@@ -125,15 +138,6 @@ func (deps *cookieSyncDeps) Endpoint(w http.ResponseWriter, r *http.Request, _ h
 	}
 	for i := 0; i < len(parsedReq.Bidders); i++ {
 		bidder := parsedReq.Bidders[i]
-		privacyPolicy := privacy.Policies{
-			GDPR: gdprPolicy.Policy{
-				Signal:  gdprToString(parsedReq.GDPR),
-				Consent: parsedReq.Consent,
-			},
-			CCPA: ccpa.Policy{
-				Signal: parsedReq.USPrivacy,
-			},
-		}
 		syncInfo, err := deps.syncers[openrtb_ext.BidderName(bidder)].GetUsersyncInfo(privacyPolicy)
 		if err == nil {
 			newSync := &usersync.CookieSyncBidders{
@@ -219,7 +223,12 @@ func (req *cookieSyncRequest) filterExistingSyncs(valid map[openrtb_ext.BidderNa
 	}
 }
 
-func (req *cookieSyncRequest) filterForGDPR(permissions gdpr.Permissions) {
+func (req *cookieSyncRequest) filterForPrivacy(permissions gdpr.Permissions, privacyPolicies privacy.Policies, enforceCCPA bool) {
+	if enforceCCPA && privacyPolicies.CCPA.ShouldEnforce() {
+		req.Bidders = nil
+		return
+	}
+
 	if req.GDPR != nil && *req.GDPR == 0 {
 		return
 	}
diff --git a/endpoints/openrtb2/amp_auction.go b/endpoints/openrtb2/amp_auction.go
index 6b64238f..d92f9d0a 100644
--- a/endpoints/openrtb2/amp_auction.go
+++ b/endpoints/openrtb2/amp_auction.go
@@ -384,13 +384,12 @@ func (deps *endpointDeps) overrideWithParams(httpRequest *http.Request, req *ope
 
 	privacyPolicies := privacy.Policies{
 		GDPR: gdpr.Policy{
-			Consent: getQueryParam(httpRequest, "gdpr_consent"),
+			Consent: httpRequest.URL.Query().Get("gdpr_consent"),
 		},
 		CCPA: ccpa.Policy{
-			Signal: getQueryParam(httpRequest, "us_privacy"),
+			Value: httpRequest.URL.Query().Get("us_privacy"),
 		},
 	}
-
 	if err := privacyPolicies.Write(req); err != nil {
 		return err
 	}
@@ -402,17 +401,6 @@ func (deps *endpointDeps) overrideWithParams(httpRequest *http.Request, req *ope
 	return nil
 }
 
-func getQueryParam(httpRequest *http.Request, name string) string {
-	values, ok := httpRequest.URL.Query()[name]
-
-	if !ok || len(values) == 0 {
-		return ""
-	}
-
-	// return first value of the query param, matching the behavior of httpRequest.FormValue
-	return values[0]
-}
-
 func makeFormatReplacement(overrideWidth uint64, overrideHeight uint64, width uint64, height uint64, multisize string) []openrtb.Format {
 	if overrideWidth != 0 && overrideHeight != 0 {
 		return []openrtb.Format{{
diff --git a/endpoints/openrtb2/auction.go b/endpoints/openrtb2/auction.go
index 61ea929e..a0ed19e5 100644
--- a/endpoints/openrtb2/auction.go
+++ b/endpoints/openrtb2/auction.go
@@ -27,6 +27,7 @@ import (
 	"github.com/prebid/prebid-server/openrtb_ext"
 	"github.com/prebid/prebid-server/pbsmetrics"
 	"github.com/prebid/prebid-server/prebid"
+	"github.com/prebid/prebid-server/privacy/ccpa"
 	"github.com/prebid/prebid-server/stored_requests"
 	"github.com/prebid/prebid-server/stored_requests/backends/empty_fetcher"
 	"github.com/prebid/prebid-server/usersync"
@@ -301,6 +302,16 @@ func (deps *endpointDeps) validateRequest(req *openrtb.BidRequest) []error {
 		return errL
 	}
 
+	ccpaPolicy, ccpaPolicyErr := ccpa.ReadPolicy(req)
+	if ccpaPolicyErr != nil {
+		errL = append(errL, ccpaPolicyErr)
+		return errL
+	}
+
+	if err := ccpaPolicy.Validate(); err != nil {
+		errL = append(errL, &errortypes.Warning{Message: fmt.Sprintf("CCPA value is invalid and will be ignored. (%s)", err.Error())})
+	}
+
 	impIDs := make(map[string]int, len(req.Imp))
 	for index := range req.Imp {
 		imp := &req.Imp[index]
diff --git a/endpoints/openrtb2/sample-requests/invalid-whole/regs-ext-malformed.json b/endpoints/openrtb2/sample-requests/invalid-whole/regs-ext-malformed.json
new file mode 100644
index 00000000..7c2805fc
--- /dev/null
+++ b/endpoints/openrtb2/sample-requests/invalid-whole/regs-ext-malformed.json
@@ -0,0 +1,45 @@
+{
+    "message": "Invalid request: request.regs.ext is invalid: json: cannot unmarshal string into Go value of type openrtb_ext.ExtRegs\n",
+    "requestPayload": {
+      "id": "b9c97a4b-cbc4-483d-b2c4-58a19ed5cfc5",
+      "site": {
+        "page": "prebid.org",
+        "publisher": {
+        "id": "a3de7af2-a86a-4043-a77b-c7e86744155e"
+        }
+      },
+      "source": {
+        "tid": "b9c97a4b-cbc4-483d-b2c4-58a19ed5cfc5"
+      },
+      "tmax": 1000,
+      "imp": [
+        {
+          "id": "/19968336/header-bid-tag-0",
+          "ext": {
+            "appnexus": {
+              "placementId": 10433394
+            }
+          },
+          "banner": {
+            "format": [
+              {
+               "w": 300,
+                "h": 250
+              },
+              {
+                "w": 300,
+                "h": 300
+              }
+            ]
+          }
+        }
+      ],
+      "regs": {
+        "ext": "malformed"
+      },
+      "user": {
+        "ext": {}
+      }
+    }
+  }
+  
\ No newline at end of file
diff --git a/endpoints/openrtb2/sample-requests/valid-whole/exemplary/all-ext.json b/endpoints/openrtb2/sample-requests/valid-whole/exemplary/all-ext.json
index a111e987..17b249a1 100644
--- a/endpoints/openrtb2/sample-requests/valid-whole/exemplary/all-ext.json
+++ b/endpoints/openrtb2/sample-requests/valid-whole/exemplary/all-ext.json
@@ -18,7 +18,8 @@
     },
     "regs": {
       "ext": {
-        "gdpr": 1
+        "gdpr": 1,
+        "us_privacy": "1NYN"
       }
     },
     "imp": [
diff --git a/endpoints/openrtb2/sample-requests/valid-whole/supplementary/ccpa-invalid.json b/endpoints/openrtb2/sample-requests/valid-whole/supplementary/ccpa-invalid.json
new file mode 100644
index 00000000..bf049304
--- /dev/null
+++ b/endpoints/openrtb2/sample-requests/valid-whole/supplementary/ccpa-invalid.json
@@ -0,0 +1,41 @@
+{
+    "id": "b9c97a4b-cbc4-483d-b2c4-58a19ed5cfc5",
+    "site": {
+      "page": "prebid.org",
+      "publisher": {
+        "id": "a3de7af2-a86a-4043-a77b-c7e86744155e"
+      }
+    },
+    "source": {
+      "tid": "b9c97a4b-cbc4-483d-b2c4-58a19ed5cfc5"
+    },
+    "tmax": 1000,
+    "imp": [
+      {
+        "id": "/19968336/header-bid-tag-0",
+        "ext": {
+          "appnexus": {
+            "placementId": 10433394
+          }
+        },
+        "banner": {
+          "format": [
+            {
+              "w": 300,
+              "h": 250
+            },
+            {
+              "w": 300,
+              "h": 300
+            }
+          ]
+        }
+      }
+    ],
+    "regs": {
+      "ext": {
+        "us_privacy": "invalid by length. allowed since it only produces a warning."
+      }
+    }
+  }
+  
\ No newline at end of file
diff --git a/exchange/exchange.go b/exchange/exchange.go
index e92e813f..071b4f8d 100644
--- a/exchange/exchange.go
+++ b/exchange/exchange.go
@@ -46,6 +46,7 @@ type exchange struct {
 	currencyConverter   *currencies.RateConverter
 	UsersyncIfAmbiguous bool
 	defaultTTLs         config.DefaultTTLs
+	enforceCCPA         bool
 }
 
 // Container to pass out response ext data from the GetAllBids goroutines back into the main thread
@@ -71,6 +72,7 @@ func NewExchange(client *http.Client, cache prebid_cache_client.Client, cfg *con
 	e.currencyConverter = currencyConverter
 	e.UsersyncIfAmbiguous = cfg.GDPR.UsersyncIfAmbiguous
 	e.defaultTTLs = cfg.CacheURL.DefaultTTLs
+	e.enforceCCPA = cfg.CCPA.Enforce
 	return e
 }
 
@@ -93,7 +95,7 @@ func (e *exchange) HoldAuction(ctx context.Context, bidRequest *openrtb.BidReque
 
 	// Slice of BidRequests, each a copy of the original cleaned to only contain bidder data for the named bidder
 	blabels := make(map[openrtb_ext.BidderName]*pbsmetrics.AdapterLabels)
-	cleanRequests, aliases, errs := cleanOpenRTBRequests(ctx, bidRequest, usersyncs, blabels, labels, e.gDPR, e.UsersyncIfAmbiguous)
+	cleanRequests, aliases, errs := cleanOpenRTBRequests(ctx, bidRequest, usersyncs, blabels, labels, e.gDPR, e.UsersyncIfAmbiguous, e.enforceCCPA)
 
 	// List of bidders we have requests for.
 	liveAdapters := listBiddersWithRequests(cleanRequests)
diff --git a/exchange/utils.go b/exchange/utils.go
index c77d907c..d1c95b88 100644
--- a/exchange/utils.go
+++ b/exchange/utils.go
@@ -5,13 +5,14 @@ import (
 	"encoding/json"
 	"fmt"
 	"math/rand"
-	"strings"
 
 	"github.com/buger/jsonparser"
 	"github.com/mxmCherry/openrtb"
 	"github.com/prebid/prebid-server/gdpr"
 	"github.com/prebid/prebid-server/openrtb_ext"
 	"github.com/prebid/prebid-server/pbsmetrics"
+	"github.com/prebid/prebid-server/privacy"
+	"github.com/prebid/prebid-server/privacy/ccpa"
 )
 
 // cleanOpenRTBRequests splits the input request into requests which are sanitized for each bidder. Intended behavior is:
@@ -25,7 +26,8 @@ func cleanOpenRTBRequests(ctx context.Context,
 	blables map[openrtb_ext.BidderName]*pbsmetrics.AdapterLabels,
 	labels pbsmetrics.Labels,
 	gDPR gdpr.Permissions,
-	usersyncIfAmbiguous bool) (requestsByBidder map[openrtb_ext.BidderName]*openrtb.BidRequest, aliases map[string]string, errs []error) {
+	usersyncIfAmbiguous,
+	enforceCCPA bool) (requestsByBidder map[openrtb_ext.BidderName]*openrtb.BidRequest, aliases map[string]string, errs []error) {
 
 	impsByBidder, errs := splitImps(orig.Imp)
 	if len(errs) > 0 {
@@ -39,34 +41,32 @@ func cleanOpenRTBRequests(ctx context.Context,
 
 	requestsByBidder, errs = splitBidRequest(orig, impsByBidder, aliases, usersyncs, blables, labels)
 
-	// Clean PI from bidrequests if not allowed per GDPR
 	gdpr := extractGDPR(orig, usersyncIfAmbiguous)
 	consent := extractConsent(orig)
-
-	// Check if it's an AMP request
 	isAMP := labels.RType == pbsmetrics.ReqTypeAMP
 
-	// Check if COPPA applies for this request
-	var applyCOPPA bool
-	if orig.Regs != nil && orig.Regs.COPPA == 1 {
-		applyCOPPA = true
+	privacyEnforcement := privacy.Enforcement{
+		COPPA: orig.Regs != nil && orig.Regs.COPPA == 1,
+	}
+
+	if enforceCCPA {
+		ccpaPolicy, _ := ccpa.ReadPolicy(orig)
+		privacyEnforcement.CCPA = ccpaPolicy.ShouldEnforce()
 	}
 
 	for bidder, bidReq := range requestsByBidder {
-		var applyGDPR bool
-		// Fixes #820
+
 		if gdpr == 1 {
 			coreBidder := resolveBidder(bidder.String(), aliases)
 
 			var publisherID = labels.PubID
-			if ok, err := gDPR.PersonalInfoAllowed(ctx, coreBidder, publisherID, consent); !ok && err == nil {
-				applyGDPR = true
-			}
+			ok, err := gDPR.PersonalInfoAllowed(ctx, coreBidder, publisherID, consent)
+			privacyEnforcement.GDPR = !ok && err == nil
+		} else {
+			privacyEnforcement.GDPR = false
 		}
 
-		if applyGDPR || applyCOPPA {
-			applyRegs(bidReq, isAMP, applyGDPR, applyCOPPA)
-		}
+		privacyEnforcement.Apply(bidReq, isAMP)
 	}
 
 	return
@@ -313,105 +313,3 @@ func randomizeList(list []openrtb_ext.BidderName) {
 		list[i], list[j] = list[j], list[i]
 	}
 }
-
-func applyRegs(bidRequest *openrtb.BidRequest, isAMP, applyGDPR, applyCOPPA bool) {
-	if bidRequest.User != nil {
-		// Need to duplicate pointer objects
-		user := *bidRequest.User
-		bidRequest.User = &user
-
-		// There's no way for AMP to send a GDPR consent string yet so it's hard
-		// to know if the vendor is consented or not and therefore for AMP requests
-		// we keep the BuyerUID as is
-		if !isAMP {
-			bidRequest.User.BuyerUID = ""
-		}
-		if applyCOPPA {
-			bidRequest.User.ID = ""
-			bidRequest.User.Yob = 0
-			bidRequest.User.Gender = ""
-			bidRequest.User.BuyerUID = ""
-		}
-		bidRequest.User.Geo = cleanGeo(bidRequest.User.Geo, applyGDPR, applyCOPPA)
-	}
-	if bidRequest.Device != nil {
-		// Need to duplicate pointer objects
-		device := *bidRequest.Device
-		bidRequest.Device = &device
-
-		bidRequest.Device.DIDMD5 = ""
-		bidRequest.Device.DIDSHA1 = ""
-		bidRequest.Device.DPIDMD5 = ""
-		bidRequest.Device.DPIDSHA1 = ""
-		if applyCOPPA {
-			bidRequest.Device.MACSHA1 = ""
-			bidRequest.Device.MACMD5 = ""
-			bidRequest.Device.IFA = ""
-		}
-		bidRequest.Device.IP = cleanIP(bidRequest.Device.IP)
-		bidRequest.Device.IPv6 = cleanIPV6(bidRequest.Device.IPv6, applyGDPR, applyCOPPA)
-		bidRequest.Device.Geo = cleanGeo(bidRequest.Device.Geo, applyGDPR, applyCOPPA)
-	}
-}
-
-// Zero the last byte of an IP address
-func cleanIP(fullIP string) string {
-	i := strings.LastIndex(fullIP, ".")
-	if i == -1 {
-		return ""
-	}
-	return fullIP[0:i] + ".0"
-}
-
-func cleanIPV6(fullIP string, applyGDPR, applyCOPPA bool) string {
-	// If neither GDPR nor COPPA applies then do nothing
-	if !applyGDPR && !applyCOPPA {
-		return fullIP
-	}
-
-	i := strings.LastIndex(fullIP, ":")
-	if i == -1 {
-		return ""
-	}
-	fullIP = fullIP[0:i]
-
-	// If COPPA then remove the lowest 32 bits of the IP
-	if applyCOPPA {
-		fullIP = fullIP[:strings.LastIndex(fullIP, ":")+1] + "0:0"
-		return fullIP
-	}
-	// If GDPR then remove the lowest 32 bits of the IP
-	if applyGDPR {
-		fullIP = fullIP + ":0"
-	}
-	return fullIP
-}
-
-// Return a cleaned Geo object pointer (round off the latitude/longitude)
-func cleanGeo(geo *openrtb.Geo, applyGDPR, applyCOPPA bool) *openrtb.Geo {
-	// If neither GDPR nor COPPA applies then do nothing
-	if !applyCOPPA && !applyGDPR {
-		return geo
-	}
-
-	if geo == nil {
-		return nil
-	}
-	newGeo := *geo
-
-	// If GDPR applies then round off the Lat and LON values
-	if applyGDPR {
-		newGeo.Lat = float64(int(geo.Lat*100.0+0.5)) / 100.0
-		newGeo.Lon = float64(int(geo.Lon*100.0+0.5)) / 100.0
-	}
-
-	// If COPPA applies then remove Lat, Lon, Metro, City and Zip values
-	if applyCOPPA {
-		newGeo.Lat = 0
-		newGeo.Lon = 0
-		newGeo.Metro = ""
-		newGeo.City = ""
-		newGeo.ZIP = ""
-	}
-	return &newGeo
-}
diff --git a/privacy/ccpa/ccpa.go b/privacy/ccpa/ccpa.go
deleted file mode 100644
index ba6ac611..00000000
--- a/privacy/ccpa/ccpa.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package ccpa
-
-import (
-	"encoding/json"
-
-	"github.com/buger/jsonparser"
-	"github.com/mxmCherry/openrtb"
-)
-
-// Policy represents the CCPA regulation for an OpenRTB bid request.
-type Policy struct {
-	Signal string
-}
-
-// Write mutates an OpenRTB bid request with the context of the CCPA policy.
-func (p Policy) Write(req *openrtb.BidRequest) error {
-	if p.Signal == "" {
-		return nil
-	}
-
-	if req.Regs == nil {
-		req.Regs = &openrtb.Regs{}
-	}
-
-	if req.Regs.Ext == nil {
-		req.Regs.Ext = json.RawMessage(`{"us_privacy":"` + p.Signal + `"}`)
-		return nil
-	}
-
-	var err error
-	req.Regs.Ext, err = jsonparser.Set(req.Regs.Ext, []byte(`"`+p.Signal+`"`), "us_privacy")
-	return err
-}
diff --git a/privacy/ccpa/policy.go b/privacy/ccpa/policy.go
new file mode 100644
index 00000000..e34a3571
--- /dev/null
+++ b/privacy/ccpa/policy.go
@@ -0,0 +1,93 @@
+package ccpa
+
+import (
+	"encoding/json"
+	"errors"
+
+	"github.com/buger/jsonparser"
+	"github.com/mxmCherry/openrtb"
+	"github.com/prebid/prebid-server/openrtb_ext"
+)
+
+// Policy represents the CCPA regulation for an OpenRTB bid request.
+type Policy struct {
+	Value string
+}
+
+// ReadPolicy extracts the CCPA regulation policy from an OpenRTB regs ext.
+func ReadPolicy(req *openrtb.BidRequest) (Policy, error) {
+	policy := Policy{}
+
+	if req != nil && req.Regs != nil && len(req.Regs.Ext) > 0 {
+		var ext openrtb_ext.ExtRegs
+		if err := json.Unmarshal(req.Regs.Ext, &ext); err != nil {
+			return policy, err
+		}
+		policy.Value = ext.USPrivacy
+	}
+
+	return policy, nil
+}
+
+// Write mutates an OpenRTB bid request with the context of the CCPA policy.
+func (p Policy) Write(req *openrtb.BidRequest) error {
+	if p.Value == "" {
+		return nil
+	}
+
+	if req.Regs == nil {
+		req.Regs = &openrtb.Regs{}
+	}
+
+	if req.Regs.Ext == nil {
+		req.Regs.Ext = json.RawMessage(`{"us_privacy":"` + p.Value + `"}`)
+		return nil
+	}
+
+	var err error
+	req.Regs.Ext, err = jsonparser.Set(req.Regs.Ext, []byte(`"`+p.Value+`"`), "us_privacy")
+	return err
+}
+
+// Validate returns an error if the CCPA regulation value does not adhere to the IAB spec.
+func (p Policy) Validate() error {
+	if p.Value == "" {
+		return nil
+	}
+
+	if len(p.Value) != 4 {
+		return errors.New("request.regs.ext.us_privacy must contain 4 characters")
+	}
+
+	if p.Value[0] != '1' {
+		return errors.New("request.regs.ext.us_privacy must specify version 1")
+	}
+
+	var c byte
+
+	c = p.Value[1]
+	if c != 'N' && c != 'Y' && c != '-' {
+		return errors.New("request.regs.ext.us_privacy must specify 'N', 'Y', or '-' for the explicit notice")
+	}
+
+	c = p.Value[2]
+	if c != 'N' && c != 'Y' && c != '-' {
+		return errors.New("request.regs.ext.us_privacy must specify 'N', 'Y', or '-' for the opt-out sale")
+	}
+
+	c = p.Value[3]
+	if c != 'N' && c != 'Y' && c != '-' {
+		return errors.New("request.regs.ext.us_privacy must specify 'N', 'Y', or '-' for the limited service provider agreement")
+	}
+
+	return nil
+}
+
+// ShouldEnforce returns true when the opt-out signal is explicitly detected.
+func (p Policy) ShouldEnforce() bool {
+	if err := p.Validate(); err != nil {
+		return false
+	}
+
+	return p.Value != "" && p.Value[2] == 'Y'
+}
diff --git a/privacy/enforcement.go b/privacy/enforcement.go
new file mode 100644
index 00000000..caea396c
--- /dev/null
+++ b/privacy/enforcement.go
@@ -0,0 +1,76 @@
+package privacy
+
+import (
+	"github.com/mxmCherry/openrtb"
+)
+
+// Enforcement represents the privacy policies to enforce for an OpenRTB bid request.
+type Enforcement struct {
+	CCPA  bool
+	COPPA bool
+	GDPR  bool
+}
+
+// Any returns true if at least one privacy policy requires enforcement.
+func (e Enforcement) Any() bool {
+	return e.CCPA || e.COPPA || e.GDPR
+}
+
+// Apply cleans personally identifiable information from an OpenRTB bid request.
+func (e Enforcement) Apply(bidRequest *openrtb.BidRequest, isAMP bool) {
+	e.apply(bidRequest, isAMP, NewScrubber())
+}
+
+func (e Enforcement) apply(bidRequest *openrtb.BidRequest, isAMP bool, scrubber Scrubber) {
+	if bidRequest != nil && e.Any() {
+		bidRequest.Device = scrubber.ScrubDevice(bidRequest.Device, e.getDeviceMacAndIFA(), e.getIPv6ScrubStrategy(), e.getGeoScrubStrategy())
+		bidRequest.User = scrubber.ScrubUser(bidRequest.User, e.getUserScrubStrategy(isAMP), e.getGeoScrubStrategy())
+	}
+}
+
+func (e Enforcement) getDeviceMacAndIFA() bool {
+	return e.COPPA
+}
+
+func (e Enforcement) getIPv6ScrubStrategy() ScrubStrategyIPV6 {
+	if e.COPPA {
+		return ScrubStrategyIPV6Lowest32
+	}
+
+	if e.GDPR || e.CCPA {
+		return ScrubStrategyIPV6Lowest16
+	}
+
+	return ScrubStrategyIPV6None
+}
+
+func (e Enforcement) getGeoScrubStrategy() ScrubStrategyGeo {
+	if e.COPPA {
+		return ScrubStrategyGeoFull
+	}
+
+	if e.GDPR || e.CCPA {
+		return ScrubStrategyGeoReducedPrecision
+	}
+
+	return ScrubStrategyGeoNone
+}
+
+func (e Enforcement) getUserScrubStrategy(isAMP bool) ScrubStrategyUser {
+	if e.COPPA {
+		return ScrubStrategyUserFull
+	}
+
+	// There's no way for AMP to send a GDPR consent string yet so it's hard
+	// to know if the vendor is consented or not and therefore for AMP requests
+	// we keep the BuyerUID as is for GDPR.
+	if e.GDPR && isAMP {
+		return ScrubStrategyUserNone
+	}
+
+	if e.GDPR || e.CCPA {
+		return ScrubStrategyUserBuyerIDOnly
+	}
+
+	return ScrubStrategyUserNone
+}
diff --git a/privacy/gdpr/gdpr.go b/privacy/gdpr/policy.go
similarity index 100%
rename from privacy/gdpr/gdpr.go
rename to privacy/gdpr/policy.go
diff --git a/privacy/gdpr/gdpr_test.go b/privacy/gdpr/policy_test.go
similarity index 100%
rename from privacy/gdpr/gdpr_test.go
rename to privacy/gdpr/policy_test.go
diff --git a/privacy/scrubber.go b/privacy/scrubber.go
new file mode 100644
index 00000000..916b660d
--- /dev/null
+++ b/privacy/scrubber.go
@@ -0,0 +1,190 @@
+package privacy
+
+import (
+	"strings"
+
+	"github.com/mxmCherry/openrtb"
+)
+
+// ScrubStrategyIPV6 defines the approach to scrub PII from an IPV6 address.
+type ScrubStrategyIPV6 int
+
+const (
+	// ScrubStrategyIPV6None does not remove any part of an IPV6 address.
+	ScrubStrategyIPV6None ScrubStrategyIPV6 = iota
+
+	// ScrubStrategyIPV6Lowest16 zeroes out the last 16 bits of an IPV6 address.
+	ScrubStrategyIPV6Lowest16
+
+	// ScrubStrategyIPV6Lowest32 zeroes out the last 32 bits of an IPV6 address.
+	ScrubStrategyIPV6Lowest32
+)
+
+// ScrubStrategyGeo defines the approach to scrub PII from geographical data.
+type ScrubStrategyGeo int
+
+const (
+	// ScrubStrategyGeoNone does not remove any geographical data.
+	ScrubStrategyGeoNone ScrubStrategyGeo = iota
+
+	// ScrubStrategyGeoFull removes all geographical data.
+	ScrubStrategyGeoFull
+
+	// ScrubStrategyGeoReducedPrecision anonymizes geographical data with rounding.
+	ScrubStrategyGeoReducedPrecision
+)
+
+// ScrubStrategyUser defines the approach to scrub PII from user data.
+type ScrubStrategyUser int
+
+const (
+	// ScrubStrategyUserNone does not remove user data.
+	ScrubStrategyUserNone ScrubStrategyUser = iota
+
+	// ScrubStrategyUserFull removes the user's buyer id, exchange id year of birth, and gender.
+	ScrubStrategyUserFull
+
+	// ScrubStrategyUserBuyerIDOnly removes the user's buyer id.
+	ScrubStrategyUserBuyerIDOnly
+)
+
+// Scrubber removes PII from parts of an OpenRTB request.
+type Scrubber interface {
+	ScrubDevice(device *openrtb.Device, macAndIFA bool, ipv6 ScrubStrategyIPV6, geo ScrubStrategyGeo) *openrtb.Device
+	ScrubUser(user *openrtb.User, strategy ScrubStrategyUser, geo ScrubStrategyGeo) *openrtb.User
+}
+
+type scrubber struct{}
+
+// NewScrubber returns an OpenRTB scrubber.
+func NewScrubber() Scrubber {
+	return scrubber{}
+}
+
+func (scrubber) ScrubDevice(device *openrtb.Device, macAndIFA bool, ipv6 ScrubStrategyIPV6, geo ScrubStrategyGeo) *openrtb.Device {
+	if device == nil {
+		return nil
+	}
+
+	deviceCopy := *device
+
+	deviceCopy.DIDMD5 = ""
+	deviceCopy.DIDSHA1 = ""
+	deviceCopy.DPIDMD5 = ""
+	deviceCopy.DPIDSHA1 = ""
+	deviceCopy.IP = scrubIPV4(device.IP)
+
+	if macAndIFA {
+		deviceCopy.MACSHA1 = ""
+		deviceCopy.MACMD5 = ""
+		deviceCopy.IFA = ""
+	}
+
+	switch ipv6 {
+	case ScrubStrategyIPV6Lowest16:
+		deviceCopy.IPv6 = scrubIPV6Lowest16Bits(device.IPv6)
+	case ScrubStrategyIPV6Lowest32:
+		deviceCopy.IPv6 = scrubIPV6Lowest32Bits(device.IPv6)
+	}
+
+	switch geo {
+	case ScrubStrategyGeoFull:
+		deviceCopy.Geo = scrubGeoFull(device.Geo)
+	case ScrubStrategyGeoReducedPrecision:
+		deviceCopy.Geo = scrubGeoPrecision(device.Geo)
+	}
+
+	return &deviceCopy
+}
+
+func (scrubber) ScrubUser(user *openrtb.User, strategy ScrubStrategyUser, geo ScrubStrategyGeo) *openrtb.User {
+	if user == nil {
+		return nil
+	}
+
+	userCopy := *user
+
+	switch strategy {
+	case ScrubStrategyUserFull:
+		userCopy.BuyerUID = ""
+		userCopy.ID = ""
+		userCopy.Yob = 0
+		userCopy.Gender = ""
+	case ScrubStrategyUserBuyerIDOnly:
+		userCopy.BuyerUID = ""
+	}
+
+	switch geo {
+	case ScrubStrategyGeoFull:
+		userCopy.Geo = scrubGeoFull(user.Geo)
+	case ScrubStrategyGeoReducedPrecision:
+		userCopy.Geo = scrubGeoPrecision(user.Geo)
+	}
+
+	return &userCopy
+}
+
+func scrubIPV4(ip string) string {
+	i := strings.LastIndex(ip, ".")
+	if i == -1 {
+		return ""
+	}
+
+	return ip[0:i] + ".0"
+}
+
+func scrubIPV6Lowest16Bits(ip string) string {
+	ip = removeLowestIPV6Segment(ip)
+
+	if ip != "" {
+		ip += ":0"
+	}
+
+	return ip
+}
+
+func scrubIPV6Lowest32Bits(ip string) string {
+	ip = removeLowestIPV6Segment(ip)
+	ip = removeLowestIPV6Segment(ip)
+
+	if ip != "" {
+		ip += ":0:0"
+	}
+
+	return ip
+}
+
+func removeLowestIPV6Segment(ip string) string {
+	i := strings.LastIndex(ip, ":")
+
+	if i == -1 {
+		return ""
+	}
+
+	return ip[0:i]
+}
+
+func scrubGeoFull(geo *openrtb.Geo) *openrtb.Geo {
+	if geo == nil {
+		return nil
+	}
+
+	geoCopy := *geo
+	geoCopy.Lat = 0
+	geoCopy.Lon = 0
+	geoCopy.Metro = ""
+	geoCopy.City = ""
+	geoCopy.ZIP = ""
+	return &geoCopy
+}
+
+func scrubGeoPrecision(geo *openrtb.Geo) *openrtb.Geo {
+	if geo == nil {
+		return nil
+	}
+
+	geoCopy := *geo
+	geoCopy.Lat = float64(int(geo.Lat*100.0+0.5)) / 100.0 // Round Latitude
+	geoCopy.Lon = float64(int(geo.Lon*100.0+0.5)) / 100.0 // Round Longitude
+	return &geoCopy
+}


Test patch:
diff --git a/adapters/gamoshi/gamoshitest/exemplary/banner-and-audio.json b/adapters/gamoshi/gamoshitest/exemplary/banner-and-audio.json
index 51c30394..b8024edd 100644
--- a/adapters/gamoshi/gamoshitest/exemplary/banner-and-audio.json
+++ b/adapters/gamoshi/gamoshitest/exemplary/banner-and-audio.json
@@ -40,7 +40,7 @@
   "httpCalls": [
     {
       "expectedRequest": {
-        "uri": "https://rtb.gamoshi.io/r/1707/bidr?reqformat=RTB_JSON",
+        "uri": "https://rtb.gamoshi.io/r/1707/bidr?bidder=prebid-server",
         "body": {
           "id": "test-request-id",
           "imp": [
diff --git a/adapters/gamoshi/gamoshitest/exemplary/banner-and-video.json b/adapters/gamoshi/gamoshitest/exemplary/banner-and-video.json
index 05608f1c..2342b49e 100644
--- a/adapters/gamoshi/gamoshitest/exemplary/banner-and-video.json
+++ b/adapters/gamoshi/gamoshitest/exemplary/banner-and-video.json
@@ -41,7 +41,7 @@
   "httpCalls": [
     {
       "expectedRequest": {
-        "uri": "https://rtb.gamoshi.io/r/1707/bidr?reqformat=RTB_JSON",
+        "uri": "https://rtb.gamoshi.io/r/1707/bidr?bidder=prebid-server",
         "body": {
           "id": "test-request-id",
           "imp": [
diff --git a/adapters/gamoshi/gamoshitest/exemplary/banner-native-audio.json b/adapters/gamoshi/gamoshitest/exemplary/banner-native-audio.json
index 71a7530b..22ce1e83 100644
--- a/adapters/gamoshi/gamoshitest/exemplary/banner-native-audio.json
+++ b/adapters/gamoshi/gamoshitest/exemplary/banner-native-audio.json
@@ -52,7 +52,7 @@
   "httpCalls": [
     {
       "expectedRequest": {
-        "uri": "https://rtb.gamoshi.io/r/1707/bidr?reqformat=RTB_JSON",
+        "uri": "https://rtb.gamoshi.io/r/1707/bidr?bidder=prebid-server",
         "body": {
           "id": "test-request-id",
           "imp": [
diff --git a/adapters/gamoshi/gamoshitest/exemplary/banner-video-native.json b/adapters/gamoshi/gamoshitest/exemplary/banner-video-native.json
index 80f4f24d..5999e379 100644
--- a/adapters/gamoshi/gamoshitest/exemplary/banner-video-native.json
+++ b/adapters/gamoshi/gamoshitest/exemplary/banner-video-native.json
@@ -53,7 +53,7 @@
   "httpCalls": [
     {
       "expectedRequest": {
-        "uri": "https://rtb.gamoshi.io/r/1707/bidr?reqformat=RTB_JSON",
+        "uri": "https://rtb.gamoshi.io/r/1707/bidr?bidder=prebid-server",
         "body": {
           "id": "test-request-id",
           "imp": [
diff --git a/adapters/gamoshi/gamoshitest/exemplary/simple-banner.json b/adapters/gamoshi/gamoshitest/exemplary/simple-banner.json
index 397da9de..2262efe9 100644
--- a/adapters/gamoshi/gamoshitest/exemplary/simple-banner.json
+++ b/adapters/gamoshi/gamoshitest/exemplary/simple-banner.json
@@ -28,7 +28,7 @@
   "httpCalls": [
     {
       "expectedRequest": {
-        "uri": "https://rtb.gamoshi.io/r/1707/bidr?reqformat=RTB_JSON",
+        "uri": "https://rtb.gamoshi.io/r/1707/bidr?bidder=prebid-server",
         "body": {
           "id": "test-request-id",
           "imp": [
diff --git a/adapters/gamoshi/gamoshitest/exemplary/simple-video.json b/adapters/gamoshi/gamoshitest/exemplary/simple-video.json
index 37b66ca8..2c10ad62 100644
--- a/adapters/gamoshi/gamoshitest/exemplary/simple-video.json
+++ b/adapters/gamoshi/gamoshitest/exemplary/simple-video.json
@@ -22,7 +22,7 @@
   "httpCalls": [
     {
       "expectedRequest": {
-        "uri": "https://rtb.gamoshi.io/r/1707/bidr?reqformat=RTB_JSON",
+        "uri": "https://rtb.gamoshi.io/r/1707/bidr?bidder=prebid-server",
         "body": {
           "id": "test-request-id",
           "imp": [
diff --git a/adapters/gamoshi/gamoshitest/exemplary/valid-extension.json b/adapters/gamoshi/gamoshitest/exemplary/valid-extension.json
index c12995c3..6806dba2 100644
--- a/adapters/gamoshi/gamoshitest/exemplary/valid-extension.json
+++ b/adapters/gamoshi/gamoshitest/exemplary/valid-extension.json
@@ -22,7 +22,7 @@
   "httpCalls": [
     {
       "expectedRequest": {
-        "uri": "https://rtb.gamoshi.io/r/1707/bidr?reqformat=RTB_JSON",
+        "uri": "https://rtb.gamoshi.io/r/1707/bidr?bidder=prebid-server",
         "body": {
           "id": "test-request-id",
           "imp": [
diff --git a/adapters/gamoshi/gamoshitest/exemplary/valid-with-device.json b/adapters/gamoshi/gamoshitest/exemplary/valid-with-device.json
index c4756470..32fd21e2 100644
--- a/adapters/gamoshi/gamoshitest/exemplary/valid-with-device.json
+++ b/adapters/gamoshi/gamoshitest/exemplary/valid-with-device.json
@@ -38,7 +38,7 @@
   "httpCalls": [
     {
       "expectedRequest": {
-        "uri": "https://rtb.gamoshi.io/r/1707/bidr?reqformat=RTB_JSON",
+        "uri": "https://rtb.gamoshi.io/r/1707/bidr?bidder=prebid-server",
         "body": {
           "id": "test-request-id",
           "device": {
diff --git a/adapters/gamoshi/gamoshitest/exemplary/video-and-audio.json b/adapters/gamoshi/gamoshitest/exemplary/video-and-audio.json
index 02b7bfb5..ec01c7b9 100644
--- a/adapters/gamoshi/gamoshitest/exemplary/video-and-audio.json
+++ b/adapters/gamoshi/gamoshitest/exemplary/video-and-audio.json
@@ -34,7 +34,7 @@
   "httpCalls": [
     {
       "expectedRequest": {
-        "uri": "https://rtb.gamoshi.io/r/1707/bidr?reqformat=RTB_JSON",
+        "uri": "https://rtb.gamoshi.io/r/1707/bidr?bidder=prebid-server",
         "body": {
           "id": "test-request-id",
           "imp": [
diff --git a/adapters/gamoshi/gamoshitest/supplemental/bad-response-no-body.json b/adapters/gamoshi/gamoshitest/supplemental/bad-response-no-body.json
index 88d66fc4..29390963 100644
--- a/adapters/gamoshi/gamoshitest/supplemental/bad-response-no-body.json
+++ b/adapters/gamoshi/gamoshitest/supplemental/bad-response-no-body.json
@@ -22,7 +22,7 @@
   "httpCalls": [
     {
       "expectedRequest": {
-        "uri": "https://rtb.gamoshi.io/r/1707/bidr?reqformat=RTB_JSON",
+        "uri": "https://rtb.gamoshi.io/r/1707/bidr?bidder=prebid-server",
         "body": {
           "id": "test-request-id",
           "imp": [
diff --git a/adapters/gamoshi/gamoshitest/supplemental/status-bad-request.json b/adapters/gamoshi/gamoshitest/supplemental/status-bad-request.json
index a0293bf6..64c75fc8 100644
--- a/adapters/gamoshi/gamoshitest/supplemental/status-bad-request.json
+++ b/adapters/gamoshi/gamoshitest/supplemental/status-bad-request.json
@@ -22,7 +22,7 @@
   "httpCalls": [
     {
       "expectedRequest": {
-        "uri": "https://rtb.gamoshi.io/r/1707/bidr?reqformat=RTB_JSON",
+        "uri": "https://rtb.gamoshi.io/r/1707/bidr?bidder=prebid-server",
         "body": {
           "id": "test-request-id",
           "imp": [
diff --git a/adapters/gamoshi/gamoshitest/supplemental/status-no-content.json b/adapters/gamoshi/gamoshitest/supplemental/status-no-content.json
index 57227e72..3bcb4a9b 100644
--- a/adapters/gamoshi/gamoshitest/supplemental/status-no-content.json
+++ b/adapters/gamoshi/gamoshitest/supplemental/status-no-content.json
@@ -22,7 +22,7 @@
   "httpCalls": [
     {
       "expectedRequest": {
-        "uri": "https://rtb.gamoshi.io/r/1707/bidr?reqformat=RTB_JSON",
+        "uri": "https://rtb.gamoshi.io/r/1707/bidr?bidder=prebid-server",
         "body": {
           "id": "test-request-id",
           "imp": [
diff --git a/adapters/gamoshi/gamoshitest/supplemental/unexpected-status-code.json b/adapters/gamoshi/gamoshitest/supplemental/unexpected-status-code.json
index acb9652f..1789707a 100644
--- a/adapters/gamoshi/gamoshitest/supplemental/unexpected-status-code.json
+++ b/adapters/gamoshi/gamoshitest/supplemental/unexpected-status-code.json
@@ -22,7 +22,7 @@
   "httpCalls": [
     {
       "expectedRequest": {
-        "uri": "https://rtb.gamoshi.io/r/1707/bidr?reqformat=RTB_JSON",
+        "uri": "https://rtb.gamoshi.io/r/1707/bidr?bidder=prebid-server",
         "body": {
           "id": "test-request-id",
           "imp": [
diff --git a/adapters/gamoshi/usersync_test.go b/adapters/gamoshi/usersync_test.go
index 10e23c02..9ba7a52f 100644
--- a/adapters/gamoshi/usersync_test.go
+++ b/adapters/gamoshi/usersync_test.go
@@ -9,7 +9,7 @@ import (
 )
 
 func TestGamoshiSyncer(t *testing.T) {
-	syncURL := "https://rtb.gamoshi.io/pix/1707/scm?gdpr={{.GDPR}}&consent={{.GDPRConsent}}&rurl=localhost/setuid%3Fbidder%3Dgamoshi%26gdpr%3D{{.GDPR}}%26gdpr_consent%3D{{.GDPRConsent}}%26uid%3D%5Bgusr%5D"
+	syncURL := "https://rtb.gamoshi.io/user_sync_prebid?gdpr={{.GDPR}}&consent={{.GDPRConsent}}&rurl=https%3A%2F%2Flocalhost%2Fsetuid%3Fbidder%3Dgamoshi%26gdpr%3D%7B%7B.GDPR%7D%7D%26gdpr_consent%3D%7B%7B.GDPRConsent%7D%7D%26uid%3D%5Bgusr%5D"
 	syncURLTemplate := template.Must(
 		template.New("sync-template").Parse(syncURL),
 	)
@@ -18,7 +18,7 @@ func TestGamoshiSyncer(t *testing.T) {
 	syncInfo, err := syncer.GetUsersyncInfo(privacy.Policies{})
 
 	assert.NoError(t, err)
-	assert.Equal(t, "https://rtb.gamoshi.io/pix/1707/scm?gdpr=&consent=&rurl=localhost/setuid%3Fbidder%3Dgamoshi%26gdpr%3D%26gdpr_consent%3D%26uid%3D%5Bgusr%5D", syncInfo.URL)
+	assert.Equal(t, "https://rtb.gamoshi.io/user_sync_prebid?gdpr=&consent=&rurl=https%3A%2F%2Flocalhost%2Fsetuid%3Fbidder%3Dgamoshi%26gdpr%3D%7B%7B.GDPR%7D%7D%26gdpr_consent%3D%7B%7B.GDPRConsent%7D%7D%26uid%3D%5Bgusr%5D", syncInfo.URL)
 	assert.Equal(t, "redirect", syncInfo.Type)
 	assert.EqualValues(t, 644, syncer.GDPRVendorID())
 	assert.Equal(t, false, syncInfo.SupportCORS)
diff --git a/adapters/syncer_test.go b/adapters/syncer_test.go
index 8defd7b6..9be52309 100644
--- a/adapters/syncer_test.go
+++ b/adapters/syncer_test.go
@@ -17,7 +17,7 @@ func TestGetUsersyncInfo(t *testing.T) {
 			Consent: "B",
 		},
 		CCPA: ccpa.Policy{
-			Signal: "C",
+			Value: "C",
 		},
 	}
 
diff --git a/adapters/triplelift_native/triplelift_native_test.go b/adapters/triplelift_native/triplelift_native_test.go
index 0beb18ed..69d05625 100644
--- a/adapters/triplelift_native/triplelift_native_test.go
+++ b/adapters/triplelift_native/triplelift_native_test.go
@@ -19,5 +19,5 @@ func TestBadConfig(t *testing.T) {
 }
 
 func TestJsonSamples(t *testing.T) {
-	adapterstest.RunJSONBidderTest(t, "triplelift_nativetest", NewTripleliftNativeBidder(nil, "http://tlx.3lift.net/s2s/auction?supplier_id=19", "{\"publisher_whitelist\":[\"foo\",\"bar\",\"baz\"], \"endpoint\":\"http://tlx.3lift.net/s2sn/auction?supplier_id=20\"}"))
+	adapterstest.RunJSONBidderTest(t, "triplelift_nativetest", NewTripleliftNativeBidder(nil, "http://tlx.3lift.net/s2sn/auction?supplier_id=20", "{\"publisher_whitelist\":[\"foo\",\"bar\",\"baz\"]}"))
 }
diff --git a/config/config_test.go b/config/config_test.go
index 4d2b0a1c..cd143c61 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -41,6 +41,8 @@ gdpr:
   host_vendor_id: 15
   usersync_if_ambiguous: true
   non_standard_publishers: ["siteID","fake-site-id","appID","agltb3B1Yi1pbmNyDAsSA0FwcBiJkfIUDA"]
+ccpa:
+  enforce: true
 host_cookie:
   cookie_name: userid
   family: prebid
@@ -102,7 +104,7 @@ adapters:
       username: rubiuser
       password: rubipw23
   brightroll:
-    usersync_url: http://test-bh.ybp.yahoo.com/sync/appnexuspbs?gdpr={{.GDPR}}&euconsent={{.GDPRConsent}}&url=%s
+    usersync_url: http://test-bh.ybp.yahoo.com/sync/appnexuspbs?gdpr={{.GDPR}}&euconsent={{.GDPRConsent}}&us_privacy={{.USPrivacy}}&url=%s
     endpoint: http://test-bid.ybp.yahoo.com/bid/appnexuspbs
   adkerneladn:
      usersync_url: https://tag.adkernel.com/syncr?gdpr={{.GDPR}}&gdpr_consent={{.GDPRConsent}}&r=
@@ -230,6 +232,8 @@ func TestFullConfig(t *testing.T) {
 	_, found = cfg.GDPR.NonStandardPublisherMap["appnexus"]
 	cmpBools(t, "cfg.GDPR.NonStandardPublisherMap", found, false)
 
+	cmpBools(t, "ccpa.enforce", cfg.CCPA.Enforce, true)
+
 	//Assert the NonStandardPublishers was correctly unmarshalled
 	cmpStrings(t, "blacklisted_apps", cfg.BlacklistedApps[0], "spamAppID")
 	cmpStrings(t, "blacklisted_apps", cfg.BlacklistedApps[1], "sketchy-app-id")
@@ -265,7 +269,7 @@ func TestFullConfig(t *testing.T) {
 	cmpStrings(t, "adapters.rubicon.xapi.username", cfg.Adapters[string(openrtb_ext.BidderRubicon)].XAPI.Username, "rubiuser")
 	cmpStrings(t, "adapters.rubicon.xapi.password", cfg.Adapters[string(openrtb_ext.BidderRubicon)].XAPI.Password, "rubipw23")
 	cmpStrings(t, "adapters.brightroll.endpoint", cfg.Adapters[string(openrtb_ext.BidderBrightroll)].Endpoint, "http://test-bid.ybp.yahoo.com/bid/appnexuspbs")
-	cmpStrings(t, "adapters.brightroll.usersync_url", cfg.Adapters[string(openrtb_ext.BidderBrightroll)].UserSyncURL, "http://test-bh.ybp.yahoo.com/sync/appnexuspbs?gdpr={{.GDPR}}&euconsent={{.GDPRConsent}}&url=%s")
+	cmpStrings(t, "adapters.brightroll.usersync_url", cfg.Adapters[string(openrtb_ext.BidderBrightroll)].UserSyncURL, "http://test-bh.ybp.yahoo.com/sync/appnexuspbs?gdpr={{.GDPR}}&euconsent={{.GDPRConsent}}&us_privacy={{.USPrivacy}}&url=%s")
 	cmpStrings(t, "adapters.adkerneladn.usersync_url", cfg.Adapters[strings.ToLower(string(openrtb_ext.BidderAdkernelAdn))].UserSyncURL, "https://tag.adkernel.com/syncr?gdpr={{.GDPR}}&gdpr_consent={{.GDPRConsent}}&r=")
 	cmpStrings(t, "adapters.rhythmone.endpoint", cfg.Adapters[string(openrtb_ext.BidderRhythmone)].Endpoint, "http://tag.1rx.io/rmp")
 	cmpStrings(t, "adapters.rhythmone.usersync_url", cfg.Adapters[string(openrtb_ext.BidderRhythmone)].UserSyncURL, "https://sync.1rx.io/usersync2/rmphb?gdpr={{.GDPR}}&gdpr_consent={{.GDPRConsent}}&redir=http%3A%2F%2Fprebid-server.prebid.org%2F%2Fsetuid%3Fbidder%3Drhythmone%26gdpr%3D{{.GDPR}}%26gdpr_consent%3D{{.GDPRConsent}}%26uid%3D%5BRX_UUID%5D")
diff --git a/endpoints/cookie_sync_test.go b/endpoints/cookie_sync_test.go
index 8f126bb9..011cecf3 100644
--- a/endpoints/cookie_sync_test.go
+++ b/endpoints/cookie_sync_test.go
@@ -65,6 +65,61 @@ func TestGDPRConsentRequired(t *testing.T) {
 	assert.Equal(t, "gdpr_consent is required if gdpr=1\n", rr.Body.String())
 }
 
+func TestCCPA(t *testing.T) {
+	testCases := []struct {
+		description   string
+		requestBody   string
+		enforceCCPA   bool
+		expectedSyncs []string
+	}{
+		{
+			description:   "Feature Flag On & Opt-Out Yes",
+			requestBody:   `{"bidders":["appnexus"], "us_privacy":"1-Y-"}`,
+			enforceCCPA:   true,
+			expectedSyncs: []string{},
+		},
+		{
+			description:   "Feature Flag Off & Opt-Out Yes",
+			requestBody:   `{"bidders":["appnexus"], "us_privacy":"1-Y-"}`,
+			enforceCCPA:   false,
+			expectedSyncs: []string{"appnexus"},
+		},
+		{
+			description:   "Feature Flag On & Opt-Out No",
+			requestBody:   `{"bidders":["appnexus"], "us_privacy":"1-N-"}`,
+			enforceCCPA:   false,
+			expectedSyncs: []string{"appnexus"},
+		},
+		{
+			description:   "Feature Flag On & Opt-Out Unknown",
+			requestBody:   `{"bidders":["appnexus"], "us_privacy":"1---"}`,
+			enforceCCPA:   false,
+			expectedSyncs: []string{"appnexus"},
+		},
+		{
+			description:   "Feature Flag On & Opt-Out Invalid",
+			requestBody:   `{"bidders":["appnexus"], "us_privacy":"invalid"}`,
+			enforceCCPA:   false,
+			expectedSyncs: []string{"appnexus"},
+		},
+		{
+			description:   "Feature Flag On & Opt-Out Not Provided",
+			requestBody:   `{"bidders":["appnexus"]}`,
+			enforceCCPA:   false,
+			expectedSyncs: []string{"appnexus"},
+		},
+	}
+
+	for _, test := range testCases {
+		gdpr := config.GDPR{UsersyncIfAmbiguous: true}
+		ccpa := config.CCPA{Enforce: test.enforceCCPA}
+		rr := doConfigurablePost(test.requestBody, nil, true, syncersForTest(), gdpr, ccpa)
+		assert.Equal(t, http.StatusOK, rr.Code, test.description+":httpResponseCode")
+		assert.ElementsMatch(t, test.expectedSyncs, parseSyncs(t, rr.Body.Bytes()), test.description+":syncs")
+		assert.Equal(t, "no_cookie", parseStatus(t, rr.Body.Bytes()), test.description+":status")
+	}
+}
+
 func TestCookieSyncHasCookies(t *testing.T) {
 	rr := doPost(`{"bidders":["appnexus", "audienceNetwork", "random"]}`, map[string]string{
 		"adnxs":           "1234",
@@ -95,7 +150,7 @@ func TestCookieSyncNoBidders(t *testing.T) {
 }
 
 func TestCookieSyncNoCookiesBrokenGDPR(t *testing.T) {
-	rr := doConfigurablePost(`{"bidders":["appnexus", "audienceNetwork", "random"],"gdpr_consent":"GLKHGKGKKGK"}`, nil, true, map[openrtb_ext.BidderName]usersync.Usersyncer{}, config.GDPR{UsersyncIfAmbiguous: true})
+	rr := doConfigurablePost(`{"bidders":["appnexus", "audienceNetwork", "random"],"gdpr_consent":"GLKHGKGKKGK"}`, nil, true, map[openrtb_ext.BidderName]usersync.Usersyncer{}, config.GDPR{UsersyncIfAmbiguous: true}, config.CCPA{})
 	assert.Equal(t, rr.Header().Get("Content-Type"), "application/json; charset=utf-8")
 	assert.Equal(t, http.StatusOK, rr.Code)
 	assert.ElementsMatch(t, []string{"appnexus", "audienceNetwork"}, parseSyncs(t, rr.Body.Bytes()))
@@ -118,15 +173,16 @@ func TestCookieSyncWithLargeLimit(t *testing.T) {
 }
 
 func doPost(body string, existingSyncs map[string]string, gdprHostConsent bool, gdprBidders map[openrtb_ext.BidderName]usersync.Usersyncer) *httptest.ResponseRecorder {
-	return doConfigurablePost(body, existingSyncs, gdprHostConsent, gdprBidders, config.GDPR{})
+	return doConfigurablePost(body, existingSyncs, gdprHostConsent, gdprBidders, config.GDPR{}, config.CCPA{})
 }
 
-func doConfigurablePost(body string, existingSyncs map[string]string, gdprHostConsent bool, gdprBidders map[openrtb_ext.BidderName]usersync.Usersyncer, cfgGDPR config.GDPR) *httptest.ResponseRecorder {
-	endpoint := testableEndpoint(mockPermissions(gdprHostConsent, gdprBidders), cfgGDPR)
+func doConfigurablePost(body string, existingSyncs map[string]string, gdprHostConsent bool, gdprBidders map[openrtb_ext.BidderName]usersync.Usersyncer, cfgGDPR config.GDPR, cfgCCPA config.CCPA) *httptest.ResponseRecorder {
+	endpoint := testableEndpoint(mockPermissions(gdprHostConsent, gdprBidders), cfgGDPR, cfgCCPA)
 	router := httprouter.New()
 	router.POST("/cookie_sync", endpoint)
 	req, _ := http.NewRequest("POST", "/cookie_sync", strings.NewReader(body))
 	if len(existingSyncs) > 0 {
+
 		pcs := usersync.NewPBSCookie()
 		for bidder, uid := range existingSyncs {
 			pcs.TrySync(bidder, uid)
@@ -139,8 +195,8 @@ func doConfigurablePost(body string, existingSyncs map[string]string, gdprHostCo
 	return rr
 }
 
-func testableEndpoint(perms gdpr.Permissions, cfgGDPR config.GDPR) httprouter.Handle {
-	return NewCookieSyncEndpoint(syncersForTest(), &config.Configuration{GDPR: cfgGDPR}, perms, &metricsConf.DummyMetricsEngine{}, analyticsConf.NewPBSAnalytics(&config.Analytics{}))
+func testableEndpoint(perms gdpr.Permissions, cfgGDPR config.GDPR, cfgCCPA config.CCPA) httprouter.Handle {
+	return NewCookieSyncEndpoint(syncersForTest(), &config.Configuration{GDPR: cfgGDPR, CCPA: cfgCCPA}, perms, &metricsConf.DummyMetricsEngine{}, analyticsConf.NewPBSAnalytics(&config.Analytics{}))
 }
 
 func syncersForTest() map[openrtb_ext.BidderName]usersync.Usersyncer {
diff --git a/endpoints/openrtb2/auction_test.go b/endpoints/openrtb2/auction_test.go
index 7f3d3d4c..c1d57e9c 100644
--- a/endpoints/openrtb2/auction_test.go
+++ b/endpoints/openrtb2/auction_test.go
@@ -900,16 +900,52 @@ func TestCurrencyTrunc(t *testing.T) {
 	}
 
 	errL := deps.validateRequest(&req)
-	for _, err := range errL {
-		if err.Error() != "A prebid request can only process one currency. Taking the first currency in the list, USD, as the active currency" {
-			t.Errorf(err.Error())
-		} else {
-			if errortypes.DecodeError(err) != errortypes.WarningCode {
-				t.Errorf("The expected warning has the wrong error type/code")
-			}
-		}
+
+	expectedError := errortypes.Warning{Message: "A prebid request can only process one currency. Taking the first currency in the list, USD, as the active currency"}
+	assert.ElementsMatch(t, errL, []error{&expectedError})
+}
+
+func TestCCPAInvalidValueWarning(t *testing.T) {
+	deps := &endpointDeps{
+		&nobidExchange{},
+		newParamsValidator(t),
+		&mockStoredReqFetcher{},
+		empty_fetcher.EmptyFetcher{},
+		empty_fetcher.EmptyFetcher{},
+		&config.Configuration{},
+		pbsmetrics.NewMetrics(metrics.NewRegistry(), openrtb_ext.BidderList(), config.DisabledMetrics{}),
+		analyticsConf.NewPBSAnalytics(&config.Analytics{}),
+		map[string]string{},
+		false,
+		[]byte{},
+		openrtb_ext.BidderMap,
+	}
+
+	ui := uint64(1)
+	req := openrtb.BidRequest{
+		ID: "someID",
+		Imp: []openrtb.Imp{
+			{
+				ID: "imp-ID",
+				Banner: &openrtb.Banner{
+					W: &ui,
+					H: &ui,
+				},
+				Ext: json.RawMessage("{\"appnexus\": {\"placementId\": 5667}}"),
+			},
+		},
+		Site: &openrtb.Site{
+			ID: "myID",
+		},
+		Regs: &openrtb.Regs{
+			Ext: json.RawMessage("{\"us_privacy\":\"invalid by length\"}"),
+		},
 	}
-	assert.Len(t, req.Cur, 1)
+
+	errL := deps.validateRequest(&req)
+
+	expectedError := errortypes.Warning{Message: "CCPA value is invalid and will be ignored. (request.regs.ext.us_privacy must contain 4 characters)"}
+	assert.ElementsMatch(t, errL, []error{&expectedError})
 }
 
 // nobidExchange is a well-behaved exchange which always bids "no bid".
diff --git a/exchange/exchange_test.go b/exchange/exchange_test.go
index 2b0e37e6..6718eb7d 100644
--- a/exchange/exchange_test.go
+++ b/exchange/exchange_test.go
@@ -631,7 +631,7 @@ func runSpec(t *testing.T, filename string, spec *exchangeSpec) {
 	if len(errs) != 0 {
 		t.Fatalf("%s: Failed to parse aliases", filename)
 	}
-	ex := newExchangeForTests(t, filename, spec.OutgoingRequests, aliases)
+	ex := newExchangeForTests(t, filename, spec.OutgoingRequests, aliases, spec.EnforceCCPA)
 	biddersInAuction := findBiddersInAuction(t, filename, &spec.IncomingRequest.OrtbRequest)
 	categoriesFetcher, error := newCategoryFetcher("./test/category-mapping")
 	if error != nil {
@@ -695,7 +695,7 @@ func extractResponseTimes(t *testing.T, context string, bid *openrtb.BidResponse
 	}
 }
 
-func newExchangeForTests(t *testing.T, filename string, expectations map[string]*bidderSpec, aliases map[string]string) Exchange {
+func newExchangeForTests(t *testing.T, filename string, expectations map[string]*bidderSpec, aliases map[string]string, enforceCCPA bool) Exchange {
 	adapters := make(map[openrtb_ext.BidderName]adaptedBidder)
 	for _, bidderName := range openrtb_ext.BidderMap {
 		if spec, ok := expectations[string(bidderName)]; ok {
@@ -733,6 +733,7 @@ func newExchangeForTests(t *testing.T, filename string, expectations map[string]
 		gDPR:                gdpr.AlwaysAllow{},
 		currencyConverter:   currencies.NewRateConverterDefault(),
 		UsersyncIfAmbiguous: false,
+		enforceCCPA:         enforceCCPA,
 	}
 }
 
@@ -1105,6 +1106,7 @@ type exchangeSpec struct {
 	IncomingRequest  exchangeRequest        `json:"incomingRequest"`
 	OutgoingRequests map[string]*bidderSpec `json:"outgoingRequests"`
 	Response         exchangeResponse       `json:"response,omitempty"`
+	EnforceCCPA      bool                   `json:"enforceCcpa"`
 }
 
 type exchangeRequest struct {
diff --git a/exchange/exchangetest/ccpa-featureflag-off.json b/exchange/exchangetest/ccpa-featureflag-off.json
new file mode 100644
index 00000000..d5445ccb
--- /dev/null
+++ b/exchange/exchangetest/ccpa-featureflag-off.json
@@ -0,0 +1,65 @@
+{
+    "enforceCcpa": false,
+    "incomingRequest": {
+        "ortbRequest": {
+            "id": "some-request-id",
+            "site": {
+                "page": "test.somepage.com"
+            },
+            "imp": [{
+                "id": "my-imp-id",
+                "video": {
+                    "mimes": ["video/mp4"]
+                },
+                "ext": {
+                    "appnexus": {
+                        "placementId": 1
+                    }
+                }
+            }],
+            "regs": {
+                "ext": {
+                    "us_privacy": "1-Y-"
+                }
+            },
+            "user": {
+                "buyeruid": "some-buyer-id"
+            }
+        }
+    },
+    "outgoingRequests": {
+        "appnexus": {
+            "expectRequest": {
+                "ortbRequest": {
+                    "id": "some-request-id",
+                    "site": {
+                        "page": "test.somepage.com"
+                    },
+                    "imp": [{
+                        "id": "my-imp-id",
+                        "video": {
+                            "mimes": ["video/mp4"]
+                        },
+                        "ext": {
+                            "bidder": {
+                                "placementId": 1
+                            }
+                        }
+                    }],
+                    "regs": {
+                        "ext": {
+                            "us_privacy": "1-Y-"
+                        }
+                    },
+                    "user": {
+                        "buyeruid": "some-buyer-id"
+                    }
+                },
+                "bidAdjustment": 1.0
+            },
+            "mockResponse": {
+                "errors": ["appnexus-error"]
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/exchange/exchangetest/ccpa-featureflag-on.json b/exchange/exchangetest/ccpa-featureflag-on.json
new file mode 100644
index 00000000..97a7c3a9
--- /dev/null
+++ b/exchange/exchangetest/ccpa-featureflag-on.json
@@ -0,0 +1,64 @@
+{
+    "enforceCcpa": true,
+    "incomingRequest": {
+        "ortbRequest": {
+            "id": "some-request-id",
+            "site": {
+                "page": "test.somepage.com"
+            },
+            "imp": [{
+                "id": "my-imp-id",
+                "video": {
+                    "mimes": ["video/mp4"]
+                },
+                "ext": {
+                    "appnexus": {
+                        "placementId": 1
+                    }
+                }
+            }],
+            "regs": {
+                "ext": {
+                    "us_privacy": "1-Y-"
+                }
+            },
+            "user": {
+                "buyeruid": "some-buyer-id"
+            }
+        }
+    },
+    "outgoingRequests": {
+        "appnexus": {
+            "expectRequest": {
+                "ortbRequest": {
+                    "id": "some-request-id",
+                    "site": {
+                        "page": "test.somepage.com"
+                    },
+                    "imp": [{
+                        "id": "my-imp-id",
+                        "video": {
+                            "mimes": ["video/mp4"]
+                        },
+                        "ext": {
+                            "bidder": {
+                                "placementId": 1
+                            }
+                        }
+                    }],
+                    "regs": {
+                        "ext": {
+                            "us_privacy": "1-Y-"
+                        }
+                    },
+                    "user": {
+                    }
+                },
+                "bidAdjustment": 1.0
+            },
+            "mockResponse": {
+                "errors": ["appnexus-error"]
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/exchange/utils_test.go b/exchange/utils_test.go
index 612fc100..4ccded46 100644
--- a/exchange/utils_test.go
+++ b/exchange/utils_test.go
@@ -41,16 +41,15 @@ func assertReq(t *testing.T, reqByBidders map[openrtb_ext.BidderName]*openrtb.Bi
 	// is used as an alias for appnexus in the test request
 	for bidderName, bidder := range reqByBidders {
 		if !applyCOPPA && consentedVendors[bidderName.String()] {
-			assert.NotEqual(t, bidder.User.BuyerUID, "", "cleanOpenRTBRequest shouldn't clean PI data as per COPPA or for a consented vendor as per GDPR")
-			assert.NotEqual(t, bidder.Device.DIDMD5, "", "cleanOpenRTBRequest shouldn't clean PI data as per COPPA or for a consented vendor as per GDPR")
+			assert.NotEqual(t, bidder.User.BuyerUID, "", "cleanOpenRTBRequest shouldn't clean PI data as per COPPA or for a consented vendor as per GDPR or per CCPA")
+			assert.NotEqual(t, bidder.Device.DIDMD5, "", "cleanOpenRTBRequest shouldn't clean PI data as per COPPA or for a consented vendor as per GDPR or per CCPA")
 		} else {
-			assert.Equal(t, bidder.User.BuyerUID, "", "cleanOpenRTBRequest should clean PI data as per COPPA or for a non-consented vendor as per GDPR ", bidderName.String())
-			assert.Equal(t, bidder.Device.DIDMD5, "", "cleanOpenRTBRequest should clean PI data as per COPPA or for a non-consented vendor as per GDPR", bidderName.String())
+			assert.Equal(t, bidder.User.BuyerUID, "", "cleanOpenRTBRequest should clean PI data as per COPPA or for a non-consented vendor as per GDPR or per CCPA", bidderName.String())
+			assert.Equal(t, bidder.Device.DIDMD5, "", "cleanOpenRTBRequest should clean PI data as per COPPA or for a non-consented vendor as per GDPR or per CCPA", bidderName.String())
 		}
 	}
 }
 
-// Prevents #820
 func TestCleanOpenRTBRequests(t *testing.T) {
 	testCases := []struct {
 		req              *openrtb.BidRequest
@@ -67,7 +66,7 @@ func TestCleanOpenRTBRequests(t *testing.T) {
 	}
 
 	for _, test := range testCases {
-		reqByBidders, _, err := cleanOpenRTBRequests(context.Background(), test.req, &emptyUsersync{}, map[openrtb_ext.BidderName]*pbsmetrics.AdapterLabels{}, pbsmetrics.Labels{}, &permissionsMock{}, true)
+		reqByBidders, _, err := cleanOpenRTBRequests(context.Background(), test.req, &emptyUsersync{}, map[openrtb_ext.BidderName]*pbsmetrics.AdapterLabels{}, pbsmetrics.Labels{}, &permissionsMock{}, true, true)
 		if test.hasError {
 			assert.NotNil(t, err, "Error shouldn't be nil")
 		} else {
@@ -77,6 +76,42 @@ func TestCleanOpenRTBRequests(t *testing.T) {
 	}
 }
 
+func TestCleanOpenRTBRequestsCCPA(t *testing.T) {
+	testCases := []struct {
+		description     string
+		enforceCCPA     bool
+		expectDataScrub bool
+	}{
+		{
+			description:     "Feature Flag Enabled",
+			enforceCCPA:     true,
+			expectDataScrub: true,
+		},
+		{
+			description:     "Feature Flag Disabled",
+			enforceCCPA:     false,
+			expectDataScrub: false,
+		},
+	}
+
+	for _, test := range testCases {
+		req := newCCPABidRequest(t)
+
+		results, _, errs := cleanOpenRTBRequests(context.Background(), req, &emptyUsersync{}, map[openrtb_ext.BidderName]*pbsmetrics.AdapterLabels{}, pbsmetrics.Labels{}, &permissionsMock{}, true, test.enforceCCPA)
+		result := results["appnexus"]
+
+		assert.Nil(t, errs)
+
+		if test.expectDataScrub {
+			assert.Equal(t, result.User.BuyerUID, "", test.description+":User.BuyerUID")
+			assert.Equal(t, result.Device.DIDMD5, "", test.description+":Device.DIDMD5")
+		} else {
+			assert.NotEqual(t, result.User.BuyerUID, "", test.description+":User.BuyerUID")
+			assert.NotEqual(t, result.Device.DIDMD5, "", test.description+":Device.DIDMD5")
+		}
+	}
+}
+
 // newAdapterAliasBidRequest builds a BidRequest with aliases
 func newAdapterAliasBidRequest(t *testing.T) *openrtb.BidRequest {
 	dnt := int8(1)
@@ -124,6 +159,51 @@ func newAdapterAliasBidRequest(t *testing.T) *openrtb.BidRequest {
 	}
 }
 
+func newCCPABidRequest(t *testing.T) *openrtb.BidRequest {
+	dnt := int8(1)
+	return &openrtb.BidRequest{
+		Site: &openrtb.Site{
+			Page:   "www.some.domain.com",
+			Domain: "domain.com",
+			Publisher: &openrtb.Publisher{
+				ID: "some-publisher-id",
+			},
+		},
+		Device: &openrtb.Device{
+			DIDMD5:   "some device ID hash",
+			UA:       "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.87 Safari/537.36",
+			IFA:      "ifa",
+			IP:       "132.173.230.74",
+			DNT:      &dnt,
+			Language: "EN",
+		},
+		Source: &openrtb.Source{
+			TID: "61018dc9-fa61-4c41-b7dc-f90b9ae80e87",
+		},
+		User: &openrtb.User{
+			ID:       "our-id",
+			BuyerUID: "their-id",
+			Ext:      json.RawMessage(`{"digitrust":{"id":"digi-id","keyv":1,"pref":1}}`),
+		},
+		Regs: &openrtb.Regs{
+			Ext: json.RawMessage(`{"us_privacy":"1-Y-"}`),
+		},
+		Imp: []openrtb.Imp{{
+			ID: "some-imp-id",
+			Banner: &openrtb.Banner{
+				Format: []openrtb.Format{{
+					W: 300,
+					H: 250,
+				}, {
+					W: 300,
+					H: 600,
+				}},
+			},
+			Ext: json.RawMessage(`{"appnexus": {"placementId": 10433394}}`),
+		}},
+	}
+}
+
 func TestRandomizeList(t *testing.T) {
 	adapters := make([]openrtb_ext.BidderName, 3)
 	adapters[0] = openrtb_ext.BidderName("dummy")
@@ -144,297 +224,3 @@ func TestRandomizeList(t *testing.T) {
 	}
 
 }
-
-func TestCleanIP(t *testing.T) {
-	testCases := []struct {
-		IP          string
-		cleanedIP   string
-		description string
-	}{
-		{
-			IP:          "0.0.0.0",
-			cleanedIP:   "0.0.0.0",
-			description: "Shouldn't do anything for a 0.0.0.0 IP address",
-		},
-		{
-			IP:          "192.127.111.134",
-			cleanedIP:   "192.127.111.0",
-			description: "Should remove the lowest 8 bits for GDPR/COPPA compliance",
-		},
-		{
-			IP:          "192.127.111.0",
-			cleanedIP:   "192.127.111.0",
-			description: "Shouldn't change anything if the lowest 8 bits are already 0",
-		},
-		{
-			IP:          "not an ip",
-			cleanedIP:   "",
-			description: "Should return an empty string for a bad IP",
-		},
-		{
-			IP:          "",
-			cleanedIP:   "",
-			description: "Should return an empty string for a bad IP",
-		},
-		{
-			IP:          "36278042",
-			cleanedIP:   "",
-			description: "Should return an empty string for a bad IP",
-		},
-	}
-
-	for _, test := range testCases {
-		assert.Equal(t, cleanIP(test.IP), test.cleanedIP, "Should properly remove the last 8 bits of the IP")
-	}
-}
-
-func TestCleanIPV6(t *testing.T) {
-	testCases := []struct {
-		IP          string
-		cleanedIP   string
-		applyGDPR   bool
-		applyCOPPA  bool
-		description string
-	}{
-		{
-			IP:          "0:0:0:0",
-			cleanedIP:   "0:0:0:0",
-			description: "Shouldn't do anything for a 0:0:0:0 IP address",
-		},
-		{
-			IP:          "2001:0db8:0000:0000:0000:ff00:0042:8329",
-			cleanedIP:   "2001:0db8:0000:0000:0000:ff00:0:0",
-			applyCOPPA:  true,
-			description: "Should remove lowest 32 bits for COPPA compliance",
-		},
-		{
-			IP:          "2001:0db8:0000:0000:0000:ff00:0042:8329",
-			cleanedIP:   "2001:0db8:0000:0000:0000:ff00:0042:0",
-			applyGDPR:   true,
-			description: "Should remove lowest 16 bits for GDPR compliance",
-		},
-		{
-			IP:          "2001:0db8:0000:0000:0000:ff00:0042:0",
-			cleanedIP:   "2001:0db8:0000:0000:0000:ff00:0042:0",
-			applyGDPR:   true,
-			description: "Shouldn't do anything if the lowest 16 bits are already 0 for GDPR compliance",
-		},
-		{
-			IP:          "2001:0db8:0000:0000:0000:ff00:0:0",
-			cleanedIP:   "2001:0db8:0000:0000:0000:ff00:0:0",
-			applyGDPR:   true,
-			description: "Shouldn't do anything if the lowest 16 bits are already 0 for GDPR compliance",
-		},
-		{
-			IP:          "2001:0db8:0000:0000:0000:ff00:0042:0",
-			cleanedIP:   "2001:0db8:0000:0000:0000:ff00:0:0",
-			applyCOPPA:  true,
-			description: "Shouldn't do anything if the lowest 32 bits are already 0 for COPPA compliance",
-		},
-		{
-			IP:          "2001:0db8:0000:0000:0000:ff00:0:0",
-			cleanedIP:   "2001:0db8:0000:0000:0000:ff00:0:0",
-			applyCOPPA:  true,
-			description: "Shouldn't do anything if the lowest 32 bits are already 0 for COPPA compliance",
-		},
-		{
-			IP:          "not an ip",
-			cleanedIP:   "",
-			applyCOPPA:  true,
-			description: "Should return an empty string for a bad IP",
-		},
-		{
-			IP:          "not an ip",
-			cleanedIP:   "",
-			applyGDPR:   true,
-			description: "Should return an empty string for a bad IP",
-		},
-		{
-			IP:          "",
-			cleanedIP:   "",
-			applyCOPPA:  true,
-			description: "Should return an empty string for a bad IP",
-		},
-	}
-
-	for _, test := range testCases {
-		assert.Equal(t, cleanIPV6(test.IP, test.applyGDPR, test.applyCOPPA), test.cleanedIP, "Should properly remove the last 8 bits of the IP")
-	}
-}
-
-func TestCleanGeo(t *testing.T) {
-	testCases := []struct {
-		geo         *openrtb.Geo
-		cleanedGeo  *openrtb.Geo
-		applyGDPR   bool
-		applyCOPPA  bool
-		description string
-	}{
-		{
-			geo: &openrtb.Geo{
-				Lat:   123.456,
-				Lon:   678.89,
-				Metro: "some metro",
-				City:  "some city",
-				ZIP:   "some zip",
-			},
-			cleanedGeo: &openrtb.Geo{
-				Lat:   123.46,
-				Lon:   678.89,
-				Metro: "some metro",
-				City:  "some city",
-				ZIP:   "some zip",
-			},
-			applyGDPR:   true,
-			description: "Should only round off Lat and Lon values for GDPR compliance",
-		},
-		{
-			geo: &openrtb.Geo{
-				Lat:   123.456,
-				Lon:   678.89,
-				Metro: "some metro",
-				City:  "some city",
-				ZIP:   "some zip",
-			},
-			cleanedGeo: &openrtb.Geo{
-				Lat:   0,
-				Lon:   0,
-				Metro: "",
-				City:  "",
-				ZIP:   "",
-			},
-			applyCOPPA:  true,
-			description: "Should suppress all Geo values for GDPR compliance",
-		},
-		{
-			geo: &openrtb.Geo{
-				Lat:   123.456,
-				Lon:   678.89,
-				Metro: "some metro",
-				City:  "some city",
-				ZIP:   "some zip",
-			},
-			cleanedGeo: &openrtb.Geo{
-				Lat:   123.456,
-				Lon:   678.89,
-				Metro: "some metro",
-				City:  "some city",
-				ZIP:   "some zip",
-			},
-			applyGDPR:   false,
-			applyCOPPA:  false,
-			description: "Should do nothing if neither GDPR nor COPPA applies",
-		},
-	}
-
-	for _, test := range testCases {
-		cleanedGeo := cleanGeo(test.geo, test.applyGDPR, test.applyCOPPA)
-		assert.Equal(t, cleanedGeo, test.cleanedGeo, test.description)
-	}
-}
-
-func TestApplyRegs(t *testing.T) {
-	bidReqOrig := openrtb.BidRequest{
-		User: &openrtb.User{
-			BuyerUID: "abc123",
-			ID:       "123",
-			Yob:      2050,
-			Gender:   "Female",
-		},
-		Device: &openrtb.Device{
-			DIDMD5:  "teapot",
-			MACSHA1: "someshahash",
-			IP:      "12.123.56.128",
-			IPv6:    "2001:0db8:85a3:0000:0000:8a2e:0370:7334",
-			Geo: &openrtb.Geo{
-				Lat: 123.4567,
-				Lon: 7.9836,
-			},
-		},
-	}
-
-	testCases := []struct {
-		bidReq      openrtb.BidRequest
-		cleanedIP   string
-		cleanedIPv6 string
-		applyCOPPA  bool
-		applyGDPR   bool
-		isAMP       bool
-		description string
-	}{
-		{
-			bidReq:      bidReqOrig,
-			cleanedIP:   "12.123.56.0",
-			cleanedIPv6: "2001:0db8:85a3:0000:0000:8a2e:0370:0",
-			applyGDPR:   true,
-			description: "Should clean recommended personal information for GDPR compliance",
-		},
-		{
-			bidReq:      bidReqOrig,
-			cleanedIP:   "12.123.56.0",
-			cleanedIPv6: "2001:0db8:85a3:0000:0000:8a2e:0370:0",
-			applyGDPR:   true,
-			isAMP:       true,
-			description: "Should clean recommended personal information for GDPR compliance",
-		},
-		{
-			bidReq:      bidReqOrig,
-			cleanedIP:   "12.123.56.0",
-			cleanedIPv6: "2001:0db8:85a3:0000:0000:8a2e:0:0",
-			applyGDPR:   true,
-			isAMP:       true,
-			applyCOPPA:  true,
-			description: "Should clean recommended personal information for GDPR compliance",
-		},
-		{
-			bidReq:      bidReqOrig,
-			cleanedIP:   "12.123.56.0",
-			cleanedIPv6: "2001:0db8:85a3:0000:0000:8a2e:0:0",
-			applyCOPPA:  true,
-			description: "Should clean recommended personal information for COPPA compliance",
-		},
-		{
-			bidReq:      openrtb.BidRequest{},
-			cleanedIP:   "",
-			cleanedIPv6: "",
-			description: "Shouldn't do anything for an empty bid request",
-		},
-	}
-
-	for _, test := range testCases {
-		// Make a shallow copy
-		bidReqCopy := test.bidReq
-		applyRegs(&bidReqCopy, test.isAMP, test.applyGDPR, test.applyCOPPA)
-
-		if bidReqCopy.User != nil {
-			if test.isAMP && !test.applyCOPPA {
-				assert.Equal(t, "abc123", bidReqCopy.User.BuyerUID, test.description)
-			} else {
-				assert.Empty(t, bidReqCopy.User.BuyerUID, test.description)
-			}
-
-			if test.applyCOPPA {
-				assert.Empty(t, bidReqCopy.User.ID, test.description)
-				assert.Empty(t, bidReqCopy.User.Yob, test.description)
-				assert.Empty(t, bidReqCopy.User.Gender, test.description)
-			}
-		}
-
-		if bidReqCopy.Device != nil {
-			if test.applyCOPPA {
-				assert.Empty(t, bidReqCopy.Device.MACSHA1, test.description)
-			}
-			assert.Empty(t, bidReqCopy.Device.DIDMD5, test.description)
-			assert.Equal(t, test.cleanedIP, bidReqCopy.Device.IP, test.description)
-			assert.Equal(t, test.cleanedIPv6, bidReqCopy.Device.IPv6, test.description)
-		}
-
-		// verify original untouched, as we want to only modify the cleaned copy for the bidder
-		assert.Equal(t, "abc123", bidReqOrig.User.BuyerUID)
-		assert.Equal(t, "teapot", bidReqOrig.Device.DIDMD5)
-		assert.Equal(t, "12.123.56.128", bidReqOrig.Device.IP)
-		assert.Equal(t, "2001:0db8:85a3:0000:0000:8a2e:0370:7334", bidReqOrig.Device.IPv6)
-		assert.Equal(t, 123.4567, bidReqOrig.Device.Geo.Lat)
-		assert.Equal(t, 7.9836, bidReqOrig.Device.Geo.Lon)
-	}
-}
diff --git a/privacy/ccpa/ccpa_test.go b/privacy/ccpa/ccpa_test.go
deleted file mode 100644
index 5847dcf0..00000000
--- a/privacy/ccpa/ccpa_test.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package ccpa
-
-import (
-	"encoding/json"
-	"testing"
-
-	"github.com/mxmCherry/openrtb"
-	"github.com/stretchr/testify/assert"
-)
-
-func TestWrite(t *testing.T) {
-	testCases := []struct {
-		description   string
-		policy        Policy
-		request       *openrtb.BidRequest
-		expected      *openrtb.BidRequest
-		expectedError bool
-	}{
-		{
-			description: "Disabled",
-			policy:      Policy{Signal: ""},
-			request:     &openrtb.BidRequest{},
-			expected:    &openrtb.BidRequest{},
-		},
-		{
-			description: "Enabled With Nil Request Regs Object",
-			policy:      Policy{Signal: "anySignal"},
-			request:     &openrtb.BidRequest{},
-			expected: &openrtb.BidRequest{Regs: &openrtb.Regs{
-				Ext: json.RawMessage(`{"us_privacy":"anySignal"}`)}},
-		},
-		{
-			description: "Enabled With Nil Request Regs Ext Object",
-			policy:      Policy{Signal: "anySignal"},
-			request:     &openrtb.BidRequest{Regs: &openrtb.Regs{}},
-			expected: &openrtb.BidRequest{Regs: &openrtb.Regs{
-				Ext: json.RawMessage(`{"us_privacy":"anySignal"}`)}},
-		},
-		{
-			description: "Enabled With Existing Request Regs Ext Object - Doesn't Overwrite",
-			policy:      Policy{Signal: "anySignal"},
-			request: &openrtb.BidRequest{Regs: &openrtb.Regs{
-				Ext: json.RawMessage(`{"existing":"any"}`)}},
-			expected: &openrtb.BidRequest{Regs: &openrtb.Regs{
-				Ext: json.RawMessage(`{"existing":"any","us_privacy":"anySignal"}`)}},
-		},
-		{
-			description: "Enabled With Existing Request Regs Ext Object - Overwrites",
-			policy:      Policy{Signal: "anySignal"},
-			request: &openrtb.BidRequest{Regs: &openrtb.Regs{
-				Ext: json.RawMessage(`{"existing":"any","us_privacy":"toBeOverwritten"}`)}},
-			expected: &openrtb.BidRequest{Regs: &openrtb.Regs{
-				Ext: json.RawMessage(`{"existing":"any","us_privacy":"anySignal"}`)}},
-		},
-		{
-			description: "Enabled With Existing Malformed Request Regs Ext Object",
-			policy:      Policy{Signal: "anySignal"},
-			request: &openrtb.BidRequest{Regs: &openrtb.Regs{
-				Ext: json.RawMessage(`malformed`)}},
-			expectedError: true,
-		},
-	}
-
-	for _, test := range testCases {
-		err := test.policy.Write(test.request)
-
-		if test.expectedError {
-			assert.Error(t, err, test.description)
-		} else {
-			assert.NoError(t, err, test.description)
-			assert.Equal(t, test.expected, test.request, test.description)
-		}
-	}
-}
diff --git a/privacy/ccpa/policy_test.go b/privacy/ccpa/policy_test.go
new file mode 100644
index 00000000..a70874eb
--- /dev/null
+++ b/privacy/ccpa/policy_test.go
@@ -0,0 +1,266 @@
+package ccpa
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/mxmCherry/openrtb"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestRead(t *testing.T) {
+	testCases := []struct {
+		description    string
+		request        *openrtb.BidRequest
+		expectedPolicy Policy
+		expectedError  bool
+	}{
+		{
+			description: "Success",
+			request: &openrtb.BidRequest{
+				Regs: &openrtb.Regs{
+					Ext: json.RawMessage(`{"us_privacy":"ABC"}`),
+				},
+			},
+			expectedPolicy: Policy{
+				Value: "ABC",
+			},
+		},
+		{
+			description: "Empty - No Request",
+			request:     nil,
+			expectedPolicy: Policy{
+				Value: "",
+			},
+		},
+		{
+			description: "Empty - No Regs",
+			request: &openrtb.BidRequest{
+				Regs: nil,
+			},
+			expectedPolicy: Policy{
+				Value: "",
+			},
+		},
+		{
+			description: "Empty - No Ext",
+			request: &openrtb.BidRequest{
+				Regs: &openrtb.Regs{},
+			},
+			expectedPolicy: Policy{
+				Value: "",
+			},
+		},
+		{
+			description: "Empty - No Value",
+			request: &openrtb.BidRequest{
+				Regs: &openrtb.Regs{
+					Ext: json.RawMessage(`{"anythingElse":"42"}`),
+				},
+			},
+			expectedPolicy: Policy{
+				Value: "",
+			},
+		},
+		{
+			description: "Serialization Issue",
+			request: &openrtb.BidRequest{
+				Regs: &openrtb.Regs{
+					Ext: json.RawMessage(`malformed`),
+				},
+			},
+			expectedError: true,
+		},
+	}
+
+	for _, test := range testCases {
+
+		p, e := ReadPolicy(test.request)
+
+		if test.expectedError {
+			assert.Error(t, e, test.description)
+		} else {
+			assert.NoError(t, e, test.description)
+		}
+
+		assert.Equal(t, test.expectedPolicy, p, test.description)
+	}
+}
+
+func TestWrite(t *testing.T) {
+	testCases := []struct {
+		description   string
+		policy        Policy
+		request       *openrtb.BidRequest
+		expected      *openrtb.BidRequest
+		expectedError bool
+	}{
+		{
+			description: "Disabled",
+			policy:      Policy{Value: ""},
+			request:     &openrtb.BidRequest{},
+			expected:    &openrtb.BidRequest{},
+		},
+		{
+			description: "Enabled With Nil Request Regs Object",
+			policy:      Policy{Value: "anyValue"},
+			request:     &openrtb.BidRequest{},
+			expected: &openrtb.BidRequest{Regs: &openrtb.Regs{
+				Ext: json.RawMessage(`{"us_privacy":"anyValue"}`)}},
+		},
+		{
+			description: "Enabled With Nil Request Regs Ext Object",
+			policy:      Policy{Value: "anyValue"},
+			request:     &openrtb.BidRequest{Regs: &openrtb.Regs{}},
+			expected: &openrtb.BidRequest{Regs: &openrtb.Regs{
+				Ext: json.RawMessage(`{"us_privacy":"anyValue"}`)}},
+		},
+		{
+			description: "Enabled With Existing Request Regs Ext Object - Doesn't Overwrite",
+			policy:      Policy{Value: "anyValue"},
+			request: &openrtb.BidRequest{Regs: &openrtb.Regs{
+				Ext: json.RawMessage(`{"existing":"any"}`)}},
+			expected: &openrtb.BidRequest{Regs: &openrtb.Regs{
+				Ext: json.RawMessage(`{"existing":"any","us_privacy":"anyValue"}`)}},
+		},
+		{
+			description: "Enabled With Existing Request Regs Ext Object - Overwrites",
+			policy:      Policy{Value: "anyValue"},
+			request: &openrtb.BidRequest{Regs: &openrtb.Regs{
+				Ext: json.RawMessage(`{"existing":"any","us_privacy":"toBeOverwritten"}`)}},
+			expected: &openrtb.BidRequest{Regs: &openrtb.Regs{
+				Ext: json.RawMessage(`{"existing":"any","us_privacy":"anyValue"}`)}},
+		},
+		{
+			description: "Enabled With Existing Malformed Request Regs Ext Object",
+			policy:      Policy{Value: "anyValue"},
+			request: &openrtb.BidRequest{Regs: &openrtb.Regs{
+				Ext: json.RawMessage(`malformed`)}},
+			expectedError: true,
+		},
+	}
+
+	for _, test := range testCases {
+		err := test.policy.Write(test.request)
+
+		if test.expectedError {
+			assert.Error(t, err, test.description)
+		} else {
+			assert.NoError(t, err, test.description)
+			assert.Equal(t, test.expected, test.request, test.description)
+		}
+	}
+}
+
+func TestValidate(t *testing.T) {
+	testCases := []struct {
+		description string
+		policy      Policy
+		expected    string
+	}{
+		{
+			description: "Valid",
+			policy:      Policy{Value: "1NYN"},
+			expected:    "",
+		},
+		{
+			description: "Valid - Not Applicable",
+			policy:      Policy{Value: "1---"},
+			expected:    "",
+		},
+		{
+			description: "Valid - Empty",
+			policy:      Policy{Value: ""},
+			expected:    "",
+		},
+		{
+			description: "Invalid Length",
+			policy:      Policy{Value: "1NY"},
+			expected:    "request.regs.ext.us_privacy must contain 4 characters",
+		},
+		{
+			description: "Invalid Version",
+			policy:      Policy{Value: "2---"},
+			expected:    "request.regs.ext.us_privacy must specify version 1",
+		},
+		{
+			description: "Invalid Explicit Notice Char",
+			policy:      Policy{Value: "1X--"},
+			expected:    "request.regs.ext.us_privacy must specify 'N', 'Y', or '-' for the explicit notice",
+		},
+		{
+			description: "Invalid Explicit Notice Case",
+			policy:      Policy{Value: "1y--"},
+			expected:    "request.regs.ext.us_privacy must specify 'N', 'Y', or '-' for the explicit notice",
+		},
+		{
+			description: "Invalid Opt-Out Sale Char",
+			policy:      Policy{Value: "1-X-"},
+			expected:    "request.regs.ext.us_privacy must specify 'N', 'Y', or '-' for the opt-out sale",
+		},
+		{
+			description: "Invalid Opt-Out Sale Case",
+			policy:      Policy{Value: "1-y-"},
+			expected:    "request.regs.ext.us_privacy must specify 'N', 'Y', or '-' for the opt-out sale",
+		},
+		{
+			description: "Invalid LSPA Char",
+			policy:      Policy{Value: "1--X"},
+			expected:    "request.regs.ext.us_privacy must specify 'N', 'Y', or '-' for the limited service provider agreement",
+		},
+		{
+			description: "Invalid LSPA Case",
+			policy:      Policy{Value: "1--y"},
+			expected:    "request.regs.ext.us_privacy must specify 'N', 'Y', or '-' for the limited service provider agreement",
+		},
+	}
+
+	for _, test := range testCases {
+		result := test.policy.Validate()
+
+		if test.expected == "" {
+			assert.NoError(t, result, test.description)
+		} else {
+			assert.EqualError(t, result, test.expected, test.description)
+		}
+	}
+}
+
+func TestShouldEnforce(t *testing.T) {
+	testCases := []struct {
+		description string
+		policy      Policy
+		expected    bool
+	}{
+		{
+			description: "Enforceable",
+			policy:      Policy{Value: "1-Y-"},
+			expected:    true,
+		},
+		{
+			description: "Not Enforceable - Not Present",
+			policy:      Policy{Value: ""},
+			expected:    false,
+		},
+		{
+			description: "Not Enforceable - Opt-Out Unknown",
+			policy:      Policy{Value: "1---"},
+			expected:    false,
+		},
+		{
+			description: "Not Enforceable - Opt-Out Explicitly No",
+			policy:      Policy{Value: "1-N-"},
+			expected:    false,
+		},
+		{
+			description: "Invalid",
+			policy:      Policy{Value: "2---"},
+			expected:    false,
+		},
+	}
+
+	for _, test := range testCases {
+		result := test.policy.ShouldEnforce()
+		assert.Equal(t, test.expected, result, test.description)
+	}
+}
diff --git a/privacy/enforcement_test.go b/privacy/enforcement_test.go
new file mode 100644
index 00000000..ffc9da5d
--- /dev/null
+++ b/privacy/enforcement_test.go
@@ -0,0 +1,214 @@
+package privacy
+
+import (
+	"testing"
+
+	"github.com/mxmCherry/openrtb"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+)
+
+func TestAny(t *testing.T) {
+	testCases := []struct {
+		enforcement Enforcement
+		expected    bool
+		description string
+	}{
+		{
+			enforcement: Enforcement{
+				CCPA:  false,
+				COPPA: false,
+				GDPR:  false,
+			},
+			expected:    false,
+			description: "All False",
+		},
+		{
+			enforcement: Enforcement{
+				CCPA:  true,
+				COPPA: true,
+				GDPR:  true,
+			},
+			expected:    true,
+			description: "All True",
+		},
+		{
+			enforcement: Enforcement{
+				CCPA:  false,
+				COPPA: true,
+				GDPR:  false,
+			},
+			expected:    true,
+			description: "Mixed",
+		},
+	}
+
+	for _, test := range testCases {
+		result := test.enforcement.Any()
+		assert.Equal(t, test.expected, result, test.description)
+	}
+}
+
+func TestApply(t *testing.T) {
+	testCases := []struct {
+		enforcement             Enforcement
+		isAMP                   bool
+		expectedDeviceMacAndIFA bool
+		expectedDeviceIPv6      ScrubStrategyIPV6
+		expectedDeviceGeo       ScrubStrategyGeo
+		expectedUser            ScrubStrategyUser
+		expectedUserGeo         ScrubStrategyGeo
+		description             string
+	}{
+		{
+			enforcement: Enforcement{
+				CCPA:  true,
+				COPPA: true,
+				GDPR:  true,
+			},
+			isAMP:                   true,
+			expectedDeviceMacAndIFA: true,
+			expectedDeviceIPv6:      ScrubStrategyIPV6Lowest32,
+			expectedDeviceGeo:       ScrubStrategyGeoFull,
+			expectedUser:            ScrubStrategyUserFull,
+			expectedUserGeo:         ScrubStrategyGeoFull,
+			description:             "All Enforced - Most Strict",
+		},
+		{
+			enforcement: Enforcement{
+				CCPA:  false,
+				COPPA: true,
+				GDPR:  false,
+			},
+			isAMP:                   false,
+			expectedDeviceMacAndIFA: true,
+			expectedDeviceIPv6:      ScrubStrategyIPV6Lowest32,
+			expectedDeviceGeo:       ScrubStrategyGeoFull,
+			expectedUser:            ScrubStrategyUserFull,
+			expectedUserGeo:         ScrubStrategyGeoFull,
+			description:             "COPPA",
+		},
+		{
+			enforcement: Enforcement{
+				CCPA:  false,
+				COPPA: false,
+				GDPR:  true,
+			},
+			isAMP:                   false,
+			expectedDeviceMacAndIFA: false,
+			expectedDeviceIPv6:      ScrubStrategyIPV6Lowest16,
+			expectedDeviceGeo:       ScrubStrategyGeoReducedPrecision,
+			expectedUser:            ScrubStrategyUserBuyerIDOnly,
+			expectedUserGeo:         ScrubStrategyGeoReducedPrecision,
+			description:             "GDPR",
+		},
+		{
+			enforcement: Enforcement{
+				CCPA:  false,
+				COPPA: false,
+				GDPR:  true,
+			},
+			isAMP:                   true,
+			expectedDeviceMacAndIFA: false,
+			expectedDeviceIPv6:      ScrubStrategyIPV6Lowest16,
+			expectedDeviceGeo:       ScrubStrategyGeoReducedPrecision,
+			expectedUser:            ScrubStrategyUserNone,
+			expectedUserGeo:         ScrubStrategyGeoReducedPrecision,
+			description:             "GDPR For AMP",
+		},
+		{
+			enforcement: Enforcement{
+				CCPA:  true,
+				COPPA: false,
+				GDPR:  false,
+			},
+			isAMP:                   false,
+			expectedDeviceMacAndIFA: false,
+			expectedDeviceIPv6:      ScrubStrategyIPV6Lowest16,
+			expectedDeviceGeo:       ScrubStrategyGeoReducedPrecision,
+			expectedUser:            ScrubStrategyUserBuyerIDOnly,
+			expectedUserGeo:         ScrubStrategyGeoReducedPrecision,
+			description:             "CCPA",
+		},
+		{
+			enforcement: Enforcement{
+				CCPA:  true,
+				COPPA: false,
+				GDPR:  false,
+			},
+			isAMP:                   true,
+			expectedDeviceMacAndIFA: false,
+			expectedDeviceIPv6:      ScrubStrategyIPV6Lowest16,
+			expectedDeviceGeo:       ScrubStrategyGeoReducedPrecision,
+			expectedUser:            ScrubStrategyUserBuyerIDOnly,
+			expectedUserGeo:         ScrubStrategyGeoReducedPrecision,
+			description:             "CCPA For AMP",
+		},
+		{
+			enforcement: Enforcement{
+				CCPA:  true,
+				COPPA: false,
+				GDPR:  true,
+			},
+			isAMP:                   true,
+			expectedDeviceMacAndIFA: false,
+			expectedDeviceIPv6:      ScrubStrategyIPV6Lowest16,
+			expectedDeviceGeo:       ScrubStrategyGeoReducedPrecision,
+			expectedUser:            ScrubStrategyUserNone,
+			expectedUserGeo:         ScrubStrategyGeoReducedPrecision,
+			description:             "GDPR And CCPA For AMP",
+		},
+	}
+
+	for _, test := range testCases {
+		req := &openrtb.BidRequest{
+			Device: &openrtb.Device{DIDSHA1: "before"},
+			User:   &openrtb.User{ID: "before"},
+		}
+		device := &openrtb.Device{DIDSHA1: "after"}
+		user := &openrtb.User{ID: "after"}
+
+		m := &mockScrubber{}
+		m.On("ScrubDevice", req.Device, test.expectedDeviceMacAndIFA, test.expectedDeviceIPv6, test.expectedDeviceGeo).Return(device).Once()
+		m.On("ScrubUser", req.User, test.expectedUser, test.expectedUserGeo).Return(user).Once()
+
+		test.enforcement.apply(req, test.isAMP, m)
+
+		m.AssertExpectations(t)
+		assert.Equal(t, device, req.Device, "Device Set Correctly")
+		assert.Equal(t, user, req.User, "User Set Correctly")
+	}
+}
+
+func TestApplyNoneApplicable(t *testing.T) {
+	enforcement := Enforcement{}
+	device := &openrtb.Device{DIDSHA1: "original"}
+	user := &openrtb.User{ID: "original"}
+	req := &openrtb.BidRequest{
+		Device: device,
+		User:   user,
+	}
+
+	m := &mockScrubber{}
+
+	enforcement.apply(req, true, m)
+
+	m.AssertNotCalled(t, "ScrubDevice")
+	m.AssertNotCalled(t, "ScrubUser")
+	assert.Equal(t, device, req.Device, "Device Set Correctly")
+	assert.Equal(t, user, req.User, "User Set Correctly")
+}
+
+type mockScrubber struct {
+	mock.Mock
+}
+
+func (m *mockScrubber) ScrubDevice(device *openrtb.Device, macAndIFA bool, ipv6 ScrubStrategyIPV6, geo ScrubStrategyGeo) *openrtb.Device {
+	args := m.Called(device, macAndIFA, ipv6, geo)
+	return args.Get(0).(*openrtb.Device)
+}
+
+func (m *mockScrubber) ScrubUser(user *openrtb.User, strategy ScrubStrategyUser, geo ScrubStrategyGeo) *openrtb.User {
+	args := m.Called(user, strategy, geo)
+	return args.Get(0).(*openrtb.User)
+}
diff --git a/privacy/scrubber_test.go b/privacy/scrubber_test.go
new file mode 100644
index 00000000..168fb5fb
--- /dev/null
+++ b/privacy/scrubber_test.go
@@ -0,0 +1,434 @@
+package privacy
+
+import (
+	"testing"
+
+	"github.com/mxmCherry/openrtb"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestScrubDevice(t *testing.T) {
+	device := &openrtb.Device{
+		DIDMD5:   "anyDIDMD5",
+		DIDSHA1:  "anyDIDSHA1",
+		DPIDMD5:  "anyDPIDMD5",
+		DPIDSHA1: "anyDPIDSHA1",
+		MACSHA1:  "anyMACSHA1",
+		MACMD5:   "anyMACMD5",
+		IFA:      "anyIFA",
+		IP:       "1.2.3.4",
+		IPv6:     "2001:0db8:0000:0000:0000:ff00:0042:8329",
+		Geo: &openrtb.Geo{
+			Lat:   123.456,
+			Lon:   678.89,
+			Metro: "some metro",
+			City:  "some city",
+			ZIP:   "some zip",
+		},
+	}
+
+	testCases := []struct {
+		expected    *openrtb.Device
+		isMacAndIFA bool
+		ipv6        ScrubStrategyIPV6
+		geo         ScrubStrategyGeo
+		description string
+	}{
+		{
+			expected: &openrtb.Device{
+				DIDMD5:   "",
+				DIDSHA1:  "",
+				DPIDMD5:  "",
+				DPIDSHA1: "",
+				MACSHA1:  "",
+				MACMD5:   "",
+				IFA:      "",
+				IP:       "1.2.3.0",
+				IPv6:     "2001:0db8:0000:0000:0000:ff00:0:0",
+				Geo:      &openrtb.Geo{},
+			},
+			isMacAndIFA: true,
+			ipv6:        ScrubStrategyIPV6Lowest32,
+			geo:         ScrubStrategyGeoFull,
+			description: "Full Scrubbing",
+		},
+		{
+			expected: &openrtb.Device{
+				DIDMD5:   "",
+				DIDSHA1:  "",
+				DPIDMD5:  "",
+				DPIDSHA1: "",
+				MACSHA1:  "",
+				MACMD5:   "",
+				IFA:      "",
+				IP:       "1.2.3.0",
+				IPv6:     "2001:0db8:0000:0000:0000:ff00:0042:0",
+				Geo:      &openrtb.Geo{},
+			},
+			isMacAndIFA: true,
+			ipv6:        ScrubStrategyIPV6Lowest16,
+			geo:         ScrubStrategyGeoFull,
+			description: "IPv6 Lowest 16",
+		},
+		{
+			expected: &openrtb.Device{
+				DIDMD5:   "",
+				DIDSHA1:  "",
+				DPIDMD5:  "",
+				DPIDSHA1: "",
+				MACSHA1:  "",
+				MACMD5:   "",
+				IFA:      "",
+				IP:       "1.2.3.0",
+				IPv6:     "2001:0db8:0000:0000:0000:ff00:0042:8329",
+				Geo:      &openrtb.Geo{},
+			},
+			isMacAndIFA: true,
+			ipv6:        ScrubStrategyIPV6None,
+			geo:         ScrubStrategyGeoFull,
+			description: "IPv6 None",
+		},
+		{
+			expected: &openrtb.Device{
+				DIDMD5:   "",
+				DIDSHA1:  "",
+				DPIDMD5:  "",
+				DPIDSHA1: "",
+				MACSHA1:  "",
+				MACMD5:   "",
+				IFA:      "",
+				IP:       "1.2.3.0",
+				IPv6:     "2001:0db8:0000:0000:0000:ff00:0:0",
+				Geo: &openrtb.Geo{
+					Lat:   123.46,
+					Lon:   678.89,
+					Metro: "some metro",
+					City:  "some city",
+					ZIP:   "some zip",
+				},
+			},
+			isMacAndIFA: true,
+			ipv6:        ScrubStrategyIPV6Lowest32,
+			geo:         ScrubStrategyGeoReducedPrecision,
+			description: "Geo Reduced Precision",
+		},
+		{
+			expected: &openrtb.Device{
+				DIDMD5:   "",
+				DIDSHA1:  "",
+				DPIDMD5:  "",
+				DPIDSHA1: "",
+				MACSHA1:  "",
+				MACMD5:   "",
+				IFA:      "",
+				IP:       "1.2.3.0",
+				IPv6:     "2001:0db8:0000:0000:0000:ff00:0:0",
+				Geo: &openrtb.Geo{
+					Lat:   123.456,
+					Lon:   678.89,
+					Metro: "some metro",
+					City:  "some city",
+					ZIP:   "some zip",
+				},
+			},
+			isMacAndIFA: true,
+			ipv6:        ScrubStrategyIPV6Lowest32,
+			geo:         ScrubStrategyGeoNone,
+			description: "Geo None",
+		},
+		{
+			expected: &openrtb.Device{
+				DIDMD5:   "",
+				DIDSHA1:  "",
+				DPIDMD5:  "",
+				DPIDSHA1: "",
+				MACSHA1:  "anyMACSHA1",
+				MACMD5:   "anyMACMD5",
+				IFA:      "anyIFA",
+				IP:       "1.2.3.0",
+				IPv6:     "2001:0db8:0000:0000:0000:ff00:0:0",
+				Geo:      &openrtb.Geo{},
+			},
+			isMacAndIFA: false,
+			ipv6:        ScrubStrategyIPV6Lowest32,
+			geo:         ScrubStrategyGeoFull,
+			description: "Without MAC Address And IFA Scrubbing",
+		},
+	}
+
+	for _, test := range testCases {
+		result := NewScrubber().ScrubDevice(device, test.isMacAndIFA, test.ipv6, test.geo)
+		assert.Equal(t, test.expected, result, test.description)
+	}
+}
+
+func TestScrubUser(t *testing.T) {
+	user := &openrtb.User{
+		BuyerUID: "anyBuyerUID",
+		ID:       "anyID",
+		Yob:      42,
+		Gender:   "anyGender",
+		Geo: &openrtb.Geo{
+			Lat:   123.456,
+			Lon:   678.89,
+			Metro: "some metro",
+			City:  "some city",
+			ZIP:   "some zip",
+		},
+	}
+
+	testCases := []struct {
+		expected    *openrtb.User
+		strategy    ScrubStrategyUser
+		geo         ScrubStrategyGeo
+		description string
+	}{
+		{
+			expected: &openrtb.User{
+				BuyerUID: "",
+				ID:       "",
+				Yob:      0,
+				Gender:   "",
+				Geo:      &openrtb.Geo{},
+			},
+			strategy:    ScrubStrategyUserFull,
+			geo:         ScrubStrategyGeoFull,
+			description: "Full Scrubbing",
+		},
+		{
+			expected: &openrtb.User{
+				BuyerUID: "",
+				ID:       "anyID",
+				Yob:      42,
+				Gender:   "anyGender",
+				Geo:      &openrtb.Geo{},
+			},
+			strategy:    ScrubStrategyUserBuyerIDOnly,
+			geo:         ScrubStrategyGeoFull,
+			description: "User Buyer ID Only",
+		},
+		{
+			expected: &openrtb.User{
+				BuyerUID: "anyBuyerUID",
+				ID:       "anyID",
+				Yob:      42,
+				Gender:   "anyGender",
+				Geo:      &openrtb.Geo{},
+			},
+			strategy:    ScrubStrategyUserNone,
+			geo:         ScrubStrategyGeoFull,
+			description: "User None",
+		},
+		{
+			expected: &openrtb.User{
+				BuyerUID: "",
+				ID:       "",
+				Yob:      0,
+				Gender:   "",
+				Geo: &openrtb.Geo{
+					Lat:   123.46,
+					Lon:   678.89,
+					Metro: "some metro",
+					City:  "some city",
+					ZIP:   "some zip",
+				},
+			},
+			strategy:    ScrubStrategyUserFull,
+			geo:         ScrubStrategyGeoReducedPrecision,
+			description: "Geo Reduced Precision",
+		},
+		{
+			expected: &openrtb.User{
+				BuyerUID: "",
+				ID:       "",
+				Yob:      0,
+				Gender:   "",
+				Geo: &openrtb.Geo{
+					Lat:   123.456,
+					Lon:   678.89,
+					Metro: "some metro",
+					City:  "some city",
+					ZIP:   "some zip",
+				},
+			},
+			strategy:    ScrubStrategyUserFull,
+			geo:         ScrubStrategyGeoNone,
+			description: "Geo None",
+		},
+	}
+
+	for _, test := range testCases {
+		result := NewScrubber().ScrubUser(user, test.strategy, test.geo)
+		assert.Equal(t, test.expected, result, test.description)
+	}
+}
+
+func TestScrubIPV4(t *testing.T) {
+	testCases := []struct {
+		IP          string
+		cleanedIP   string
+		description string
+	}{
+		{
+			IP:          "0.0.0.0",
+			cleanedIP:   "0.0.0.0",
+			description: "Shouldn't do anything for a 0.0.0.0 IP address",
+		},
+		{
+			IP:          "192.127.111.134",
+			cleanedIP:   "192.127.111.0",
+			description: "Should remove the lowest 8 bits",
+		},
+		{
+			IP:          "192.127.111.0",
+			cleanedIP:   "192.127.111.0",
+			description: "Shouldn't change anything if the lowest 8 bits are already 0",
+		},
+		{
+			IP:          "not an ip",
+			cleanedIP:   "",
+			description: "Should return an empty string for a bad IP",
+		},
+		{
+			IP:          "",
+			cleanedIP:   "",
+			description: "Should return an empty string for a bad IP",
+		},
+	}
+
+	for _, test := range testCases {
+		result := scrubIPV4(test.IP)
+		assert.Equal(t, test.cleanedIP, result, test.description)
+	}
+}
+
+func TestScrubIPV6Lowest16Bits(t *testing.T) {
+	testCases := []struct {
+		IP          string
+		cleanedIP   string
+		description string
+	}{
+		{
+			IP:          "0:0:0:0",
+			cleanedIP:   "0:0:0:0",
+			description: "Shouldn't do anything for a 0:0:0:0 IP address",
+		},
+		{
+			IP:          "2001:0db8:0000:0000:0000:ff00:0042:8329",
+			cleanedIP:   "2001:0db8:0000:0000:0000:ff00:0042:0",
+			description: "Should remove lowest 16 bits",
+		},
+		{
+			IP:          "2001:0db8:0000:0000:0000:ff00:0042:0",
+			cleanedIP:   "2001:0db8:0000:0000:0000:ff00:0042:0",
+			description: "Shouldn't do anything if the lowest 16 bits are already 0",
+		},
+		{
+			IP:          "not an ip",
+			cleanedIP:   "",
+			description: "Should return an empty string for a bad IP",
+		},
+		{
+			IP:          "",
+			cleanedIP:   "",
+			description: "Should return an empty string for a bad IP",
+		},
+	}
+
+	for _, test := range testCases {
+		result := scrubIPV6Lowest16Bits(test.IP)
+		assert.Equal(t, test.cleanedIP, result, test.description)
+	}
+}
+
+func TestScrubIPV6Lowest32Bits(t *testing.T) {
+	testCases := []struct {
+		IP          string
+		cleanedIP   string
+		description string
+	}{
+		{
+			IP:          "0:0:0:0",
+			cleanedIP:   "0:0:0:0",
+			description: "Shouldn't do anything for a 0:0:0:0 IP address",
+		},
+		{
+			IP:          "2001:0db8:0000:0000:0000:ff00:0042:8329",
+			cleanedIP:   "2001:0db8:0000:0000:0000:ff00:0:0",
+			description: "Should remove lowest 32 bits",
+		},
+		{
+			IP:          "2001:0db8:0000:0000:0000:ff00:0:0",
+			cleanedIP:   "2001:0db8:0000:0000:0000:ff00:0:0",
+			description: "Shouldn't do anything if the lowest 32 bits are already 0",
+		},
+
+		{
+			IP:          "not an ip",
+			cleanedIP:   "",
+			description: "Should return an empty string for a bad IP",
+		},
+		{
+			IP:          "",
+			cleanedIP:   "",
+			description: "Should return an empty string for a bad IP",
+		},
+	}
+
+	for _, test := range testCases {
+		result := scrubIPV6Lowest32Bits(test.IP)
+		assert.Equal(t, test.cleanedIP, result, test.description)
+	}
+}
+
+func TestScrubGeoFull(t *testing.T) {
+	geo := &openrtb.Geo{
+		Lat:   123.456,
+		Lon:   678.89,
+		Metro: "some metro",
+		City:  "some city",
+		ZIP:   "some zip",
+	}
+	geoExpected := &openrtb.Geo{
+		Lat:   0,
+		Lon:   0,
+		Metro: "",
+		City:  "",
+		ZIP:   "",
+	}
+
+	result := scrubGeoFull(geo)
+
+	assert.Equal(t, geoExpected, result)
+}
+
+func TestScrubGeoFullWhenNil(t *testing.T) {
+	result := scrubGeoFull(nil)
+	assert.Nil(t, result)
+}
+
+func TestScrubGeoPrecision(t *testing.T) {
+	geo := &openrtb.Geo{
+		Lat:   123.456,
+		Lon:   678.89,
+		Metro: "some metro",
+		City:  "some city",
+		ZIP:   "some zip",
+	}
+	geoExpected := &openrtb.Geo{
+		Lat:   123.46,
+		Lon:   678.89,
+		Metro: "some metro",
+		City:  "some city",
+		ZIP:   "some zip",
+	}
+
+	result := scrubGeoPrecision(geo)
+
+	assert.Equal(t, geoExpected, result)
+}
+
+func TestScrubGeoPrecisionWhenNil(t *testing.T) {
+	result := scrubGeoPrecision(nil)
+	assert.Nil(t, result)
+}