kingfisher/src/access_map/square.rs
2026-04-01 10:20:52 -07:00

328 lines
10 KiB
Rust

use anyhow::{anyhow, Context, Result};
use reqwest::{header, Client};
use serde::Deserialize;
use tracing::warn;
use crate::{cli::commands::access_map::AccessMapArgs, validation::GLOBAL_USER_AGENT};
use super::{
build_recommendations, AccessMapResult, AccessSummary, AccessTokenDetails, PermissionSummary,
ResourceExposure, RoleBinding, Severity,
};
const SQUARE_API: &str = "https://connect.squareup.com";
const SQUARE_VERSION: &str = "2024-01-18";
#[derive(Deserialize)]
struct SquareMerchantResponse {
#[serde(default)]
merchant: Vec<SquareMerchant>,
}
#[derive(Deserialize)]
struct SquareMerchant {
#[serde(default)]
id: Option<String>,
#[serde(default)]
business_name: Option<String>,
#[serde(default)]
country: Option<String>,
#[allow(dead_code)]
#[serde(default)]
currency: Option<String>,
#[allow(dead_code)]
#[serde(default)]
status: Option<String>,
}
#[derive(Deserialize)]
struct SquareLocationsResponse {
#[serde(default)]
locations: Vec<SquareLocation>,
}
#[derive(Deserialize)]
struct SquareLocation {
#[allow(dead_code)]
#[serde(default)]
id: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
status: Option<String>,
#[serde(default, rename = "type")]
location_type: Option<String>,
#[serde(default)]
capabilities: Vec<String>,
}
pub async fn map_access(args: &AccessMapArgs) -> Result<AccessMapResult> {
let token = if let Some(path) = args.credential_path.as_deref() {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read Square token from {}", path.display()))?;
raw.trim().to_string()
} else {
return Err(anyhow!("Square access-map requires a validated token from scan results"));
};
map_access_from_token(&token).await
}
pub async fn map_access_from_token(token: &str) -> Result<AccessMapResult> {
let client = Client::builder()
.user_agent(GLOBAL_USER_AGENT.as_str())
.build()
.context("Failed to build Square HTTP client")?;
let key_type = classify_key_type(token);
let merchant_resp = fetch_merchant(&client, token).await?;
let merchant = merchant_resp.merchant.first();
let merchant_id = merchant.and_then(|m| m.id.clone()).unwrap_or_else(|| "unknown".to_string());
let business_name = merchant.and_then(|m| m.business_name.clone());
let display_name = business_name.clone().unwrap_or_else(|| merchant_id.clone());
let identity = AccessSummary {
id: display_name.clone(),
access_type: key_type.label.to_string(),
project: None,
tenant: merchant.and_then(|m| m.country.clone()),
account_id: merchant.and_then(|m| m.id.clone()),
};
let mut risk_notes = Vec::new();
let mut resources = Vec::new();
let mut permissions = PermissionSummary::default();
let mut roles = Vec::new();
let mut detected_scopes: Vec<String> = Vec::new();
// Merchant-level resource.
permissions.admin.push("merchant:read".to_string());
detected_scopes.push("merchant:read".to_string());
// Enumerate locations.
let locations = list_locations(&client, token).await.unwrap_or_else(|err| {
warn!("Square access-map: locations enumeration failed: {err}");
Vec::new()
});
if !locations.is_empty() {
permissions.read_only.push("locations:read".to_string());
detected_scopes.push("locations:read".to_string());
}
for loc in &locations {
let loc_name = loc.name.clone().unwrap_or_else(|| "unknown_location".to_string());
let loc_type = loc.location_type.clone().unwrap_or_default();
let loc_status = loc.status.clone().unwrap_or_default();
let has_cc = loc.capabilities.iter().any(|c| c == "CREDIT_CARD_PROCESSING");
resources.push(ResourceExposure {
resource_type: "location".into(),
name: loc_name,
permissions: loc.capabilities.clone(),
risk: severity_to_str(if has_cc { Severity::Medium } else { Severity::Low })
.to_string(),
reason: format!(
"Square location ({}, {}){}",
loc_type,
loc_status,
if has_cc { " with credit card processing" } else { "" }
),
});
}
// Probe additional capabilities.
let probes: &[(&str, &str, ScopeRisk)] = &[
("/v2/customers?limit=1", "customers:read", ScopeRisk::Risky),
("/v2/payments?limit=1", "payments:read", ScopeRisk::Risky),
("/v2/catalog/list?limit=1", "catalog:read", ScopeRisk::Read),
];
for (endpoint, scope_name, risk) in probes {
match probe_endpoint(&client, token, endpoint).await {
Ok(true) => {
detected_scopes.push(scope_name.to_string());
match risk {
ScopeRisk::Admin => permissions.admin.push(scope_name.to_string()),
ScopeRisk::Risky => permissions.risky.push(scope_name.to_string()),
ScopeRisk::Read => permissions.read_only.push(scope_name.to_string()),
}
}
Ok(false) => {}
Err(err) => {
warn!("Square access-map: probe for {scope_name} failed: {err}");
}
}
}
roles.push(RoleBinding {
name: format!("key_type:{}", key_type.label),
source: "square".into(),
permissions: detected_scopes.clone(),
});
// Account resource.
let has_payments = detected_scopes.iter().any(|s| s == "payments:read");
let has_customers = detected_scopes.iter().any(|s| s == "customers:read");
resources.push(ResourceExposure {
resource_type: "merchant".into(),
name: merchant_id.clone(),
permissions: detected_scopes.clone(),
risk: severity_to_str(if has_payments || has_customers {
Severity::High
} else {
Severity::Medium
})
.to_string(),
reason: format!("Square merchant accessible via {} token", key_type.label),
});
if has_payments {
risk_notes.push("Token can access payment data (financial transactions)".into());
}
if has_customers {
risk_notes.push("Token can access customer data (PII)".into());
}
if key_type.is_oauth {
risk_notes.push("OAuth token — may have broad scopes granted during authorization".into());
}
permissions.admin.sort();
permissions.admin.dedup();
permissions.risky.sort();
permissions.risky.dedup();
permissions.read_only.sort();
permissions.read_only.dedup();
let severity = derive_severity(has_payments, has_customers, &detected_scopes);
Ok(AccessMapResult {
cloud: "square".into(),
identity,
roles,
permissions,
resources,
severity,
recommendations: build_recommendations(severity),
risk_notes,
token_details: Some(AccessTokenDetails {
name: business_name,
username: None,
account_type: Some(key_type.label.to_string()),
company: merchant.and_then(|m| m.business_name.clone()),
location: merchant.and_then(|m| m.country.clone()),
email: None,
url: None,
token_type: Some(key_type.label.to_string()),
created_at: None,
last_used_at: None,
expires_at: None,
user_id: merchant.and_then(|m| m.id.clone()),
scopes: detected_scopes,
}),
provider_metadata: None,
fingerprint: None,
})
}
async fn fetch_merchant(client: &Client, token: &str) -> Result<SquareMerchantResponse> {
let resp = client
.get(format!("{SQUARE_API}/v2/merchants/me"))
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header("Square-Version", SQUARE_VERSION)
.header(header::ACCEPT, "application/json")
.send()
.await
.context("Square access-map: failed to fetch merchant info")?;
if !resp.status().is_success() {
return Err(anyhow!(
"Square access-map: merchant lookup failed with HTTP {}",
resp.status()
));
}
resp.json().await.context("Square access-map: invalid merchant JSON")
}
async fn list_locations(client: &Client, token: &str) -> Result<Vec<SquareLocation>> {
let resp = client
.get(format!("{SQUARE_API}/v2/locations"))
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header("Square-Version", SQUARE_VERSION)
.header(header::ACCEPT, "application/json")
.send()
.await
.context("Square access-map: failed to list locations")?;
if !resp.status().is_success() {
warn!("Square access-map: locations enumeration failed with HTTP {}", resp.status());
return Ok(Vec::new());
}
let body: SquareLocationsResponse =
resp.json().await.context("Square access-map: invalid locations JSON")?;
Ok(body.locations)
}
async fn probe_endpoint(client: &Client, token: &str, endpoint: &str) -> Result<bool> {
let resp = client
.get(format!("{SQUARE_API}{endpoint}"))
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header("Square-Version", SQUARE_VERSION)
.header(header::ACCEPT, "application/json")
.send()
.await
.context("Square access-map: probe request failed")?;
Ok(resp.status().is_success())
}
struct KeyClassification {
label: &'static str,
is_oauth: bool,
}
fn classify_key_type(token: &str) -> KeyClassification {
if token.starts_with("EAAA") {
KeyClassification { label: "oauth_token", is_oauth: true }
} else if token.starts_with("sq0atp-") {
KeyClassification { label: "personal_access_token", is_oauth: false }
} else {
KeyClassification { label: "unknown_token", is_oauth: false }
}
}
enum ScopeRisk {
#[allow(dead_code)]
Admin,
Risky,
Read,
}
fn derive_severity(has_payments: bool, has_customers: bool, scopes: &[String]) -> Severity {
if has_payments || has_customers {
return Severity::High;
}
let has_catalog = scopes.iter().any(|s| s == "catalog:read");
let has_locations = scopes.iter().any(|s| s == "locations:read");
if has_catalog || has_locations {
return Severity::Medium;
}
Severity::Low
}
fn severity_to_str(severity: Severity) -> &'static str {
match severity {
Severity::Low => "low",
Severity::Medium => "medium",
Severity::High => "high",
Severity::Critical => "critical",
}
}