Permissions
@@ -1298,7 +1363,7 @@
return JSON.parse(text);
} catch (e) {
const trimmed = text.trim();
- const parts = trimmed.split(/\n(?={)/g);
+ const parts = trimmed.split(/\r?\n(?=\s*{)/g).filter(Boolean);
if (parts.length > 1) {
try {
return JSON.parse("[" + parts.join(",") + "]");
@@ -1308,6 +1373,53 @@
return null;
}
+ function collectReportData(entries) {
+ const findings = [];
+ const accessMap = [];
+ let mainReport = null;
+ let statsReport = null;
+
+ const items = Array.isArray(entries) ? entries : [entries];
+ items.forEach((item) => {
+ if (!item || typeof item !== "object") return;
+
+ if (Array.isArray(item.findings) || Array.isArray(item.access_map)) {
+ if (!mainReport) {
+ mainReport = item;
+ }
+ }
+
+ if (Array.isArray(item.findings)) {
+ findings.push(...item.findings);
+ }
+
+ if (Array.isArray(item.access_map)) {
+ accessMap.push(...item.access_map);
+ }
+
+ if (item.rule && item.finding) {
+ findings.push(item);
+ }
+
+ if (item.provider && item.account) {
+ accessMap.push(item);
+ }
+
+ if (
+ typeof item.scan_duration !== "undefined" ||
+ typeof item.scanDuration !== "undefined" ||
+ typeof item.bytes_scanned !== "undefined" ||
+ item.kingfisher ||
+ item.stats ||
+ item.summary
+ ) {
+ statsReport = item;
+ }
+ });
+
+ return { findings, accessMap, mainReport, statsReport };
+ }
+
function processFile(file) {
loaderText.textContent = 'Processing "' + file.name + '"…';
loader.classList.remove("hidden");
@@ -1408,64 +1520,33 @@
let parsed = parsePossiblyMultiJson(text);
if (parsed === null) {
- const lines = text.split("\\n");
- const tmpFindings = [];
- const tmpAccessMap = [];
- let mainReport = null;
- let statsReport = null;
+ const lines = text.split(/\r?\n/);
+ const entries = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line || line[0] !== "{") continue;
try {
const obj = JSON.parse(line);
- if (!mainReport && (Array.isArray(obj.findings) || Array.isArray(obj.access_map))) {
- mainReport = obj;
- }
- if (typeof obj.scan_duration !== "undefined" || obj.kingfisher || typeof obj.bytes_scanned !== "undefined") {
- statsReport = obj;
- }
- if (obj.findings) tmpFindings.push(...obj.findings);
- else if (obj.access_map) tmpAccessMap.push(...obj.access_map);
- else if (obj.rule && obj.finding) tmpFindings.push(obj);
- else if (obj.provider && obj.account) tmpAccessMap.push(obj);
+ entries.push(obj);
} catch (errLine) {
console.warn("Skipping invalid JSON line", i);
}
}
- findings = tmpFindings;
- accessMap = tmpAccessMap;
- rawData = Object.assign({}, mainReport || {}, statsReport || {});
- } else {
- let mainReport = null;
- let statsReport = null;
-
- if (Array.isArray(parsed)) {
- parsed.forEach((item) => {
- if (!item || typeof item !== "object") return;
- if (!mainReport && (Array.isArray(item.findings) || Array.isArray(item.access_map))) {
- mainReport = item;
- }
- if (typeof item.scan_duration !== "undefined" || item.kingfisher || typeof item.bytes_scanned !== "undefined") {
- statsReport = item;
- }
- });
- } else {
- const item = parsed;
- if (Array.isArray(item.findings) || Array.isArray(item.access_map)) {
- mainReport = item;
- }
- if (typeof item.scan_duration !== "undefined" || item.kingfisher || typeof item.bytes_scanned !== "undefined") {
- statsReport = item;
- }
+ const collected = collectReportData(entries);
+ findings = collected.findings;
+ accessMap = collected.accessMap;
+ if (collected.mainReport || collected.statsReport) {
+ rawData = Object.assign({}, collected.mainReport || {}, collected.statsReport || {});
+ }
+ } else {
+ const collected = collectReportData(parsed);
+ findings = collected.findings;
+ accessMap = collected.accessMap;
+ if (collected.mainReport || collected.statsReport || parsed) {
+ rawData = Object.assign({}, collected.mainReport || {}, collected.statsReport || {});
}
-
- const dataForFindings = mainReport || statsReport || {};
- findings = Array.isArray(dataForFindings.findings) ? dataForFindings.findings : [];
- accessMap = Array.isArray(dataForFindings.access_map) ? dataForFindings.access_map : [];
-
- rawData = Object.assign({}, dataForFindings, statsReport || {});
}
currentPage = 1;
@@ -2180,9 +2261,9 @@
filteredAccessMapView.forEach((entry) => {
const identity = entry.identity;
- const account = identity.account || "unknown-identity";
+ const account = formatIdentityLabel(identity);
const identityNameMatches = entry.identityNameMatches;
- const idNode = createTreeNode(account, "identity", true);
+ const idNode = createTreeNode(account, "identity", true, identity.provider);
idNode.container.style.borderLeft = "none";
idNode.container.style.marginLeft = "0";
idNode.header.addEventListener("click", (e) => {
@@ -2248,7 +2329,7 @@
const identities = [];
accessMap.forEach((identity) => {
- const account = identity.account || "unknown-identity";
+ const account = formatIdentityLabel(identity);
const groups = Array.isArray(identity.groups) ? identity.groups : [];
const identityNameMatches = Boolean(filterLower) && account.toLowerCase().includes(filterLower);
@@ -2315,7 +2396,7 @@
}
}
- function createTreeNode(label, type, isOpen) {
+ function createTreeNode(label, type, isOpen, provider) {
const container = document.createElement("div");
container.className = "tree-node";
@@ -2331,8 +2412,8 @@
const icon = document.createElement("span");
icon.className = "node-icon icon-" + type;
if (type === "identity") icon.textContent = "👤";
- else if (type === "resource") icon.textContent = "Hx";
- else if (type === "group") icon.textContent = "📁";
+ else if (type === "resource") icon.textContent = "📦";
+ else if (type === "group") icon.textContent = "🗂️";
const text = document.createElement("span");
text.textContent = label;
@@ -2343,6 +2424,9 @@
header.appendChild(toggle);
header.appendChild(icon);
header.appendChild(text);
+ if (type === "identity" && provider) {
+ addBadge(header, provider.toUpperCase(), providerBadgeClass(provider));
+ }
const children = document.createElement("div");
children.className = "tree-children";
@@ -2395,6 +2479,22 @@
container.appendChild(span);
}
+ function providerBadgeClass(provider) {
+ const normalized = String(provider || "").toLowerCase();
+ if (normalized === "aws") return "badge-aws";
+ if (normalized === "gcp") return "badge-gcp";
+ if (normalized === "azure") return "badge-azure";
+ if (normalized === "github") return "badge-github";
+ if (normalized === "gitlab") return "badge-gitlab";
+ return "badge-aws";
+ }
+
+ function formatIdentityLabel(identity) {
+ const base = identity && identity.account ? identity.account : "unknown-identity";
+ const provider = identity && identity.provider ? identity.provider.toUpperCase() : null;
+ return provider ? `[${provider}] ${base}` : base;
+ }
+
function setDetailName(text, link) {
const nameEl = document.getElementById("am-detail-name");
nameEl.innerHTML = "";
@@ -2553,10 +2653,28 @@
const cloudField = document.getElementById("am-detail-cloud");
const permsContainer = document.getElementById("am-perms-container");
const permsList = document.getElementById("am-perms-list");
+ const tokenContainer = document.getElementById("am-token-container");
+ const tokenName = document.getElementById("am-token-name");
+ const tokenUsername = document.getElementById("am-token-username");
+ const tokenType = document.getElementById("am-token-type");
+ const tokenAccountType = document.getElementById("am-token-account-type");
+ const tokenUser = document.getElementById("am-token-user");
+ const tokenCompany = document.getElementById("am-token-company");
+ const tokenCreated = document.getElementById("am-token-created");
+ const tokenLocation = document.getElementById("am-token-location");
+ const tokenLastUsed = document.getElementById("am-token-last-used");
+ const tokenEmail = document.getElementById("am-token-email");
+ const tokenExpires = document.getElementById("am-token-expires");
+ const tokenUrl = document.getElementById("am-token-url");
+ const tokenVersion = document.getElementById("am-token-version");
+ const tokenEnterprise = document.getElementById("am-token-enterprise");
+ const tokenScopes = document.getElementById("am-token-scopes");
meta.innerHTML = "";
permsList.innerHTML = "";
permsContainer.classList.add("hidden");
+ tokenScopes.innerHTML = "";
+ tokenContainer.classList.add("hidden");
let detailName = "";
let detailLink = null;
@@ -2569,7 +2687,44 @@
const provider = (data.provider || "unknown").toUpperCase();
cloudField.textContent = provider;
if (data.provider) {
- addBadge(meta, provider, data.provider === "gcp" ? "badge-gcp" : "badge-aws");
+ addBadge(meta, provider, providerBadgeClass(data.provider));
+ }
+ if (data.token_details) {
+ const details = data.token_details;
+ tokenName.textContent = details.name || "-";
+ tokenUsername.textContent = details.username || "-";
+ tokenType.textContent = details.token_type || "-";
+ tokenAccountType.textContent = details.account_type || "-";
+ tokenUser.textContent = details.user_id || "-";
+ tokenCompany.textContent = details.company || "-";
+ tokenCreated.textContent = details.created_at || "-";
+ tokenLocation.textContent = details.location || "-";
+ tokenLastUsed.textContent = details.last_used_at || "-";
+ tokenEmail.textContent = details.email || "-";
+ tokenExpires.textContent = details.expires_at || "-";
+ if (details.url) {
+ tokenUrl.innerHTML = "";
+ const urlLink = document.createElement("a");
+ urlLink.href = details.url;
+ urlLink.target = "_blank";
+ urlLink.rel = "noreferrer noopener";
+ urlLink.textContent = details.url;
+ tokenUrl.appendChild(urlLink);
+ } else {
+ tokenUrl.textContent = "-";
+ }
+ tokenVersion.textContent =
+ (data.provider_metadata && data.provider_metadata.version) || "-";
+ tokenEnterprise.textContent =
+ data.provider_metadata && typeof data.provider_metadata.enterprise === "boolean"
+ ? data.provider_metadata.enterprise
+ ? "true"
+ : "false"
+ : "-";
+ if (Array.isArray(details.scopes)) {
+ details.scopes.forEach((scope) => addBadge(tokenScopes, scope, "badge-perm"));
+ }
+ tokenContainer.classList.remove("hidden");
}
} else if (type === "resource") {
icon.textContent = "📦";
@@ -2582,7 +2737,7 @@
const provider = (data.provider || "unknown").toUpperCase();
cloudField.textContent = provider;
if (data.provider) {
- addBadge(meta, provider, data.provider === "gcp" ? "badge-gcp" : "badge-aws");
+ addBadge(meta, provider, providerBadgeClass(data.provider));
}
if (Array.isArray(data.permissions) && data.permissions.length) {
diff --git a/docs/kingfisher-usage-01.gif b/docs/kingfisher-usage-01.gif
index 623ea51..2f61a88 100644
Binary files a/docs/kingfisher-usage-01.gif and b/docs/kingfisher-usage-01.gif differ
diff --git a/docs/kingfisher-usage-access-map-01.gif b/docs/kingfisher-usage-access-map-01.gif
new file mode 100644
index 0000000..40ea5e8
Binary files /dev/null and b/docs/kingfisher-usage-access-map-01.gif differ
diff --git a/docs/kingfisher-usage-access-map.gif b/docs/kingfisher-usage-access-map.gif
deleted file mode 100644
index 7a9e5cb..0000000
Binary files a/docs/kingfisher-usage-access-map.gif and /dev/null differ
diff --git a/src/access_map.rs b/src/access_map.rs
index 3e2114f..7b4d1b2 100644
--- a/src/access_map.rs
+++ b/src/access_map.rs
@@ -1,11 +1,15 @@
-use anyhow::{bail, Result};
+use anyhow::Result;
+use schemars::JsonSchema;
use serde::Serialize;
use crate::cli::commands::access_map::{AccessMapArgs, AccessMapProvider};
mod aws;
mod azure;
+mod azure_devops;
mod gcp;
+mod github;
+mod gitlab;
mod report;
/// Run the identity mapping workflow for the selected cloud provider.
@@ -14,6 +18,8 @@ pub async fn run(args: AccessMapArgs) -> Result<()> {
AccessMapProvider::Gcp => gcp::map_access(args.credential_path.as_deref()).await?,
AccessMapProvider::Aws => aws::map_access(&args).await?,
AccessMapProvider::Azure => azure::map_access(&args).await?,
+ AccessMapProvider::Github => github::map_access(&args).await?,
+ AccessMapProvider::Gitlab => gitlab::map_access(&args).await?,
};
let json = serde_json::to_string_pretty(&result)?;
@@ -37,6 +43,14 @@ pub enum AccessMapRequest {
Aws { access_key: String, secret_key: String, session_token: Option
},
/// A GCP service account JSON document.
Gcp { credential_json: String },
+ /// An Azure storage account JSON document.
+ Azure { credential_json: String, containers: Option> },
+ /// An Azure DevOps personal access token with organization.
+ AzureDevops { token: String, organization: String },
+ /// A GitHub token.
+ Github { token: String },
+ /// A GitLab token.
+ Gitlab { token: String },
}
/// Structured output describing the resolved identity and its risk profile.
@@ -62,6 +76,13 @@ pub struct AccessMapResult {
pub recommendations: Vec,
/// Additional risk notes derived from permissions and impersonation exposure.
pub risk_notes: Vec,
+
+ /// Optional access token metadata (for GitHub/GitLab).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub token_details: Option,
+ /// Optional provider metadata (for GitLab instance details, etc.).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub provider_metadata: Option,
}
/// Identity details such as email or ARN.
@@ -131,6 +152,32 @@ pub enum Severity {
Critical,
}
+/// Optional metadata for access tokens.
+#[derive(Debug, Serialize, Clone, Default, JsonSchema)]
+pub struct AccessTokenDetails {
+ pub name: Option,
+ pub username: Option,
+ pub account_type: Option,
+ pub company: Option,
+ pub location: Option,
+ pub email: Option,
+ pub url: Option,
+ pub token_type: Option,
+ pub created_at: Option,
+ pub last_used_at: Option,
+ pub expires_at: Option,
+ pub user_id: Option,
+ #[serde(skip_serializing_if = "Vec::is_empty", default)]
+ pub scopes: Vec,
+}
+
+/// Optional metadata about the provider instance.
+#[derive(Debug, Serialize, Clone, Default, JsonSchema)]
+pub struct ProviderMetadata {
+ pub version: Option,
+ pub enterprise: Option,
+}
+
/// Map a batch of credentials to their effective identities.
pub async fn map_requests(requests: Vec) -> Vec {
let mut results = Vec::new();
@@ -147,6 +194,22 @@ pub async fn map_requests(requests: Vec) -> Vec {
+ azure::map_access_from_json_with_hints(&credential_json, containers.as_deref())
+ .await
+ .unwrap_or_else(|err| build_failed_result("azure", "storage_account", err))
+ }
+ AccessMapRequest::AzureDevops { token, organization } => {
+ azure_devops::map_access_from_token(&token, &organization)
+ .await
+ .unwrap_or_else(|err| build_failed_result("azure_devops", "pat", err))
+ }
+ AccessMapRequest::Github { token } => github::map_access_from_token(&token)
+ .await
+ .unwrap_or_else(|err| build_failed_result("github", "token", err)),
+ AccessMapRequest::Gitlab { token } => gitlab::map_access_from_token(&token)
+ .await
+ .unwrap_or_else(|err| build_failed_result("gitlab", "token", err)),
};
results.push(mapped);
@@ -186,6 +249,8 @@ fn build_failed_result(cloud: &str, identity_label: &str, err: anyhow::Error) ->
severity: Severity::Medium,
recommendations: build_recommendations(Severity::Medium),
risk_notes: vec![format!("Identity mapping failed: {err}")],
+ token_details: None,
+ provider_metadata: None,
}
}
@@ -234,7 +299,7 @@ pub(crate) fn build_recommendations(severity: Severity) -> Vec {
recs
}
-/// Fallback handler for unsupported providers.
-async fn unsupported_provider(provider: &AccessMapProvider) -> Result {
- bail!("Identity mapping for {:?} is not implemented", provider)
-}
+// /// Fallback handler for unsupported providers.
+// async fn unsupported_provider(provider: &AccessMapProvider) -> Result {
+// bail!("Identity mapping for {:?} is not implemented", provider)
+// }
diff --git a/src/access_map/aws.rs b/src/access_map/aws.rs
index de74e70..c3b3ba6 100644
--- a/src/access_map/aws.rs
+++ b/src/access_map/aws.rs
@@ -20,7 +20,7 @@ use crate::cli::commands::access_map::AccessMapArgs;
use super::{
build_default_account_resource, build_recommendations, AccessMapResult, AccessSummary,
- PermissionSummary, ResourceExposure, RoleBinding, Severity,
+ AccessTokenDetails, PermissionSummary, ResourceExposure, RoleBinding, Severity,
};
pub async fn map_access(args: &AccessMapArgs) -> Result {
@@ -138,6 +138,22 @@ async fn map_access_with_config(config: SdkConfig) -> Result {
severity,
recommendations,
risk_notes,
+ token_details: Some(AccessTokenDetails {
+ name: account_id.clone(),
+ username: None,
+ account_type: None,
+ company: None,
+ location: None,
+ email: None,
+ url: None,
+ token_type: Some("access_key".into()),
+ created_at: None,
+ last_used_at: None,
+ expires_at: None,
+ user_id: Some(arn.clone()),
+ scopes: Vec::new(),
+ }),
+ provider_metadata: None,
})
}
diff --git a/src/access_map/azure.rs b/src/access_map/azure.rs
index 29c762e..6ed7d95 100644
--- a/src/access_map/azure.rs
+++ b/src/access_map/azure.rs
@@ -1,9 +1,220 @@
-use anyhow::Result;
+use anyhow::{anyhow, Context, Result};
+use base64::{engine::general_purpose::STANDARD as b64, Engine as _};
+use chrono::Utc;
+use hmac::{Hmac, Mac};
+use quick_xml::{events::Event, Reader};
+use reqwest::{header::HeaderValue, Client};
+use serde_json::Value as JsonValue;
+use sha2::Sha256;
use crate::cli::commands::access_map::AccessMapArgs;
-use super::AccessMapResult;
+use super::{
+ build_recommendations, AccessMapResult, AccessSummary, PermissionSummary, ResourceExposure,
+ RoleBinding, Severity,
+};
pub async fn map_access(args: &AccessMapArgs) -> Result {
- super::unsupported_provider(&args.provider).await
+ let path = args
+ .credential_path
+ .as_deref()
+ .ok_or_else(|| anyhow!("Azure access-map requires a credential JSON path"))?;
+ let data = std::fs::read_to_string(path).context("Failed to read credential file")?;
+ map_access_from_json(&data).await
+}
+
+pub async fn map_access_from_json(data: &str) -> Result {
+ map_access_from_json_with_hints(data, None).await
+}
+
+pub async fn map_access_from_json_with_hints(
+ data: &str,
+ containers_hint: Option<&[String]>,
+) -> Result {
+ let (storage_account, storage_key) = parse_storage_credentials(data)?;
+
+ let mut risk_notes =
+ vec!["Storage account keys grant full control over the storage account".to_string()];
+
+ let containers = match containers_hint {
+ Some(list) if !list.is_empty() => list.to_vec(),
+ _ => match list_containers(&storage_account, &storage_key).await {
+ Ok(list) => list,
+ Err(err) => {
+ risk_notes.push(format!("Container enumeration failed: {err}"));
+ Vec::new()
+ }
+ },
+ };
+
+ let severity = Severity::Critical;
+ let permissions =
+ PermissionSummary { admin: vec!["storage:*".into()], ..PermissionSummary::default() };
+
+ let roles = vec![RoleBinding {
+ name: "storage_account_key".into(),
+ source: "shared_key".into(),
+ permissions: vec!["storage:*".into()],
+ }];
+
+ let mut resources = Vec::new();
+ resources.push(ResourceExposure {
+ resource_type: "storage_account".into(),
+ name: storage_account.clone(),
+ permissions: vec!["storage:*".into()],
+ risk: "critical".into(),
+ reason: "Storage account accessible with shared key".into(),
+ });
+
+ if containers.is_empty() {
+ resources.push(ResourceExposure {
+ resource_type: "storage_container".into(),
+ name: String::new(),
+ permissions: vec!["storage:*".into()],
+ risk: "critical".into(),
+ reason: "Container list unavailable; storage account key still grants full access"
+ .into(),
+ });
+ } else {
+ for container in containers {
+ resources.push(ResourceExposure {
+ resource_type: "storage_container".into(),
+ name: container,
+ permissions: vec!["storage:*".into()],
+ risk: "critical".into(),
+ reason: "Container accessible with shared key".into(),
+ });
+ }
+ }
+
+ let identity = AccessSummary {
+ id: storage_account,
+ access_type: "storage_account_key".into(),
+ project: None,
+ tenant: None,
+ account_id: None,
+ };
+
+ Ok(AccessMapResult {
+ cloud: "azure".into(),
+ identity,
+ roles,
+ permissions,
+ resources,
+ severity,
+ recommendations: build_recommendations(severity),
+ risk_notes,
+ token_details: None,
+ provider_metadata: None,
+ })
+}
+
+fn parse_storage_credentials(data: &str) -> Result<(String, String)> {
+ let token: JsonValue = serde_json::from_str(data)?;
+ let storage_account = token["storage_account"]
+ .as_str()
+ .ok_or_else(|| anyhow!("Missing storage_account in credential JSON"))?
+ .to_string();
+ let storage_key = token["storage_key"]
+ .as_str()
+ .ok_or_else(|| anyhow!("Missing storage_key in credential JSON"))?
+ .to_string();
+ Ok((storage_account, storage_key))
+}
+
+async fn list_containers(storage_account: &str, storage_key: &str) -> Result> {
+ let mut containers = std::collections::BTreeSet::new();
+ let mut marker: Option = None;
+
+ loop {
+ let now_rfc = Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string();
+ let mut url = reqwest::Url::parse(&format!(
+ "https://{account}.blob.core.windows.net/",
+ account = storage_account
+ ))?;
+ {
+ let mut query = url.query_pairs_mut();
+ query.append_pair("comp", "list");
+ if let Some(marker_value) = marker.as_deref() {
+ query.append_pair("marker", marker_value);
+ }
+ }
+
+ let canon_headers = format!("x-ms-date:{now_rfc}\nx-ms-version:2023-11-03\n");
+ let mut canon_resource = format!("/{account}/\ncomp:list", account = storage_account);
+ if let Some(marker_value) = marker.as_deref() {
+ canon_resource.push_str(&format!("\nmarker:{marker_value}"));
+ }
+ let string_to_sign = format!(
+ "GET\n\n\n\n\n\n\n\n\n\n\n\n{headers}{resource}",
+ headers = canon_headers,
+ resource = canon_resource
+ );
+
+ let key_bytes = b64.decode(storage_key)?;
+ let mut mac = Hmac::::new_from_slice(&key_bytes)
+ .map_err(|_| anyhow!("invalid key length"))?;
+ mac.update(string_to_sign.as_bytes());
+ let signature = b64.encode(mac.finalize().into_bytes());
+
+ let mut headers = reqwest::header::HeaderMap::new();
+ headers.insert("x-ms-date", HeaderValue::from_str(&now_rfc)?);
+ headers.insert("x-ms-version", HeaderValue::from_static("2023-11-03"));
+ headers.insert(
+ "Authorization",
+ HeaderValue::from_str(&format!(
+ "SharedKey {account}:{sig}",
+ account = storage_account,
+ sig = signature
+ ))?,
+ );
+
+ let client = Client::builder().build()?;
+ let resp = client.get(url).headers(headers).send().await?;
+ let status = resp.status();
+ let body_txt = resp.text().await?;
+
+ if !status.is_success() {
+ return Err(anyhow!(
+ "Azure Storage list containers failed (HTTP {}): {}",
+ status,
+ body_txt
+ ));
+ }
+
+ let mut reader = Reader::from_str(&body_txt);
+ reader.config_mut().trim_text(true);
+ let mut buf = Vec::new();
+ let mut next_marker: Option = None;
+
+ loop {
+ match reader.read_event_into(&mut buf) {
+ Ok(Event::Eof) => break,
+ Ok(Event::Start(e)) if e.name().as_ref().eq_ignore_ascii_case(b"name") => {
+ let text = reader.read_text(e.name())?;
+ let name = text.into_owned();
+ if !name.is_empty() {
+ containers.insert(name);
+ }
+ }
+ Ok(Event::Start(e)) if e.name().as_ref().eq_ignore_ascii_case(b"nextmarker") => {
+ let text = reader.read_text(e.name())?;
+ let value = text.into_owned();
+ if !value.trim().is_empty() {
+ next_marker = Some(value);
+ }
+ }
+ Err(e) => return Err(anyhow!("XML parse error: {e}")),
+ _ => {}
+ }
+ buf.clear();
+ }
+
+ if next_marker.is_none() {
+ break;
+ }
+ marker = next_marker;
+ }
+
+ Ok(containers.into_iter().collect())
}
diff --git a/src/access_map/azure_devops.rs b/src/access_map/azure_devops.rs
new file mode 100644
index 0000000..0cf4b92
--- /dev/null
+++ b/src/access_map/azure_devops.rs
@@ -0,0 +1,619 @@
+use anyhow::{anyhow, Context, Result};
+use base64::{engine::general_purpose::STANDARD as b64, Engine as _};
+use reqwest::{header, Client, Url};
+use serde::Deserialize;
+use tracing::warn;
+
+use crate::validation::GLOBAL_USER_AGENT;
+
+use super::{
+ build_recommendations, AccessMapResult, AccessSummary, AccessTokenDetails, PermissionSummary,
+ ResourceExposure, RoleBinding, Severity,
+};
+
+const AZURE_DEVOPS_PROFILE_API: &str =
+ "https://app.vssps.visualstudio.com/_apis/profile/profiles/me?api-version=7.1-preview.1";
+const AZURE_DEVOPS_API_VERSION: &str = "7.1-preview.1";
+const AZURE_DEVOPS_TOKEN_ADMIN_VERSION: &str = "7.1";
+
+#[derive(Deserialize)]
+struct AzureDevopsProfile {
+ #[serde(rename = "displayName")]
+ display_name: Option,
+ #[serde(rename = "publicAlias")]
+ public_alias: Option,
+ #[serde(rename = "emailAddress")]
+ email_address: Option,
+ id: Option,
+}
+
+#[derive(Deserialize)]
+struct AzureDevopsProject {
+ name: String,
+ #[serde(default)]
+ visibility: Option,
+ #[serde(default)]
+ _state: Option,
+}
+
+#[derive(Deserialize)]
+struct AzureDevopsRepo {
+ name: String,
+ #[serde(rename = "isDisabled", default)]
+ is_disabled: bool,
+ #[serde(default)]
+ project: AzureDevopsProjectRef,
+}
+
+#[derive(Deserialize, Default)]
+struct AzureDevopsProjectRef {
+ name: Option,
+}
+
+#[derive(Deserialize)]
+struct AzureDevopsListResponse {
+ value: Vec,
+}
+
+#[derive(Deserialize)]
+struct AzureDevopsIdentity {
+ #[serde(rename = "subjectDescriptor")]
+ subject_descriptor: Option,
+}
+
+#[derive(Clone, Deserialize)]
+struct AzureDevopsPat {
+ #[serde(rename = "displayName")]
+ display_name: Option,
+ #[serde(rename = "validFrom")]
+ valid_from: Option,
+ #[serde(rename = "validTo")]
+ valid_to: Option,
+ #[serde(rename = "userId")]
+ user_id: Option,
+ scope: Option,
+}
+
+pub async fn map_access_from_token(token: &str, organization: &str) -> Result {
+ let org = normalize_org(organization);
+ if org.is_empty() {
+ return Err(anyhow!("Azure DevOps access-map requires a valid organization name"));
+ }
+
+ let client = Client::builder()
+ .user_agent(GLOBAL_USER_AGENT.as_str())
+ .build()
+ .context("Failed to build Azure DevOps HTTP client")?;
+ let auth_header = build_auth_header(token)?;
+
+ let (profile, scopes, user_data) = match fetch_profile(&client, auth_header.clone()).await {
+ Ok(value) => value,
+ Err(err) => {
+ warn!("Azure DevOps access-map: profile lookup failed: {err}");
+ (
+ AzureDevopsProfile {
+ display_name: None,
+ public_alias: None,
+ email_address: None,
+ id: None,
+ },
+ Vec::new(),
+ AzureDevopsUserData::default(),
+ )
+ }
+ };
+ let pat_details =
+ fetch_pat_details(&client, &org, auth_header.clone(), &profile, &scopes).await;
+ let projects = list_projects(&client, &org, auth_header.clone()).await?;
+ let repos = list_repositories(&client, &org, auth_header.clone(), &projects).await?;
+
+ let identity_id = profile
+ .email_address
+ .clone()
+ .or_else(|| user_data.email.clone())
+ .or(profile.public_alias.clone())
+ .or(profile.display_name.clone())
+ .or(profile.id.clone())
+ .or_else(|| user_data.user_id.clone())
+ .unwrap_or_else(|| "azure_devops_user".to_string());
+
+ let identity = AccessSummary {
+ id: identity_id,
+ access_type: "pat".into(),
+ project: Some(org.clone()),
+ tenant: None,
+ account_id: None,
+ };
+
+ let mut resources = Vec::new();
+ let mut permissions = PermissionSummary::default();
+ let mut risk_notes = Vec::new();
+
+ let mut seen_repos = std::collections::BTreeSet::new();
+ for repo in &repos {
+ let risk = if repo.is_disabled { Severity::Low } else { Severity::Medium };
+ let reason = if repo.is_disabled {
+ "Repository is disabled but visible to the token".to_string()
+ } else {
+ "Accessible Azure DevOps repository".to_string()
+ };
+
+ let mut repo_permissions = Vec::new();
+ repo_permissions.push("repo:read".to_string());
+ permissions.read_only.push("repo:read".to_string());
+
+ let repo_name = match repo.project.name.as_deref() {
+ Some(project_name) if !project_name.is_empty() => {
+ format!("{}/{}", project_name, repo.name)
+ }
+ _ => repo.name.clone(),
+ };
+
+ if !seen_repos.insert(repo_name.clone()) {
+ continue;
+ }
+
+ resources.push(ResourceExposure {
+ resource_type: "repository".into(),
+ name: repo_name,
+ permissions: repo_permissions,
+ risk: severity_to_str(risk).to_string(),
+ reason,
+ });
+ }
+
+ permissions.read_only.sort();
+ permissions.read_only.dedup();
+
+ let severity = derive_severity(&projects, &repos);
+
+ let mut roles = Vec::new();
+ if !scopes.is_empty() {
+ roles.push(RoleBinding {
+ name: "token_scopes".into(),
+ source: "azure_devops".into(),
+ permissions: scopes.clone(),
+ });
+ }
+
+ if repos.is_empty() {
+ for project in &projects {
+ let is_private = project
+ .visibility
+ .as_deref()
+ .map(|v| v.eq_ignore_ascii_case("private"))
+ .unwrap_or(false);
+ let risk = if is_private { Severity::Medium } else { Severity::Low };
+ let reason = if is_private {
+ "Accessible private Azure DevOps project".to_string()
+ } else {
+ "Accessible public Azure DevOps project".to_string()
+ };
+
+ resources.push(ResourceExposure {
+ resource_type: "project".into(),
+ name: project.name.clone(),
+ permissions: vec!["project:read".to_string()],
+ risk: severity_to_str(risk).to_string(),
+ reason,
+ });
+ }
+
+ if projects.is_empty() {
+ resources.push(ResourceExposure {
+ resource_type: "organization".into(),
+ name: org.clone(),
+ permissions: Vec::new(),
+ risk: severity_to_str(Severity::Low).to_string(),
+ reason: "Azure DevOps organization associated with the token".into(),
+ });
+ }
+
+ risk_notes.push("Token did not enumerate any repositories".into());
+ }
+
+ if roles.is_empty() {
+ risk_notes
+ .push("Azure DevOps did not report PAT scopes; review the token permissions".into());
+ }
+
+ let pat_scopes =
+ pat_details.as_ref().map(|pat| parse_pat_scopes(pat.scope.as_deref())).unwrap_or_default();
+ let token_scopes = if scopes.is_empty() { pat_scopes.clone() } else { scopes.clone() };
+
+ Ok(AccessMapResult {
+ cloud: "azure_devops".into(),
+ identity,
+ roles,
+ permissions,
+ resources,
+ severity,
+ recommendations: build_recommendations(severity),
+ risk_notes,
+ token_details: Some(AccessTokenDetails {
+ name: pat_details
+ .as_ref()
+ .and_then(|pat| pat.display_name.clone())
+ .filter(|value| !value.trim().is_empty())
+ .or_else(|| {
+ profile
+ .display_name
+ .clone()
+ .or(profile.public_alias.clone())
+ .filter(|value| !value.trim().is_empty())
+ }),
+ username: profile.public_alias.clone().filter(|value| !value.trim().is_empty()),
+ account_type: None,
+ company: None,
+ location: None,
+ email: profile.email_address.clone().filter(|value| !value.trim().is_empty()),
+ url: None,
+ token_type: Some("pat".into()),
+ created_at: pat_details.as_ref().and_then(|pat| pat.valid_from.clone()),
+ last_used_at: None,
+ expires_at: pat_details.as_ref().and_then(|pat| pat.valid_to.clone()),
+ user_id: pat_details
+ .as_ref()
+ .and_then(|pat| pat.user_id.clone())
+ .or(profile.id.clone())
+ .or_else(|| user_data.user_id.clone())
+ .or(profile.email_address.clone())
+ .or(profile.public_alias.clone()),
+ scopes: token_scopes,
+ }),
+ provider_metadata: None,
+ })
+}
+
+#[derive(Default)]
+struct AzureDevopsUserData {
+ user_id: Option,
+ email: Option,
+}
+
+async fn fetch_profile(
+ client: &Client,
+ auth_header: header::HeaderValue,
+) -> Result<(AzureDevopsProfile, Vec, AzureDevopsUserData)> {
+ let profile_url = Url::parse(AZURE_DEVOPS_PROFILE_API).expect("valid Azure DevOps profile URL");
+ let resp = client
+ .get(profile_url)
+ .header(header::AUTHORIZATION, auth_header)
+ .send()
+ .await
+ .context("Azure DevOps access-map: failed to fetch user profile")?;
+
+ if !resp.status().is_success() {
+ return Err(anyhow!(
+ "Azure DevOps access-map: profile lookup failed with HTTP {}",
+ resp.status()
+ ));
+ }
+
+ let scopes = resp
+ .headers()
+ .get("x-vss-token-scopes")
+ .and_then(|val| val.to_str().ok())
+ .map(|value| {
+ value
+ .split(',')
+ .map(|scope| scope.trim().to_string())
+ .filter(|scope| !scope.is_empty())
+ .collect::>()
+ })
+ .unwrap_or_default();
+
+ let user_data = parse_user_data(resp.headers().get("x-vss-userdata"));
+ let profile = resp.json().await.context("Azure DevOps access-map: invalid profile JSON")?;
+
+ Ok((profile, scopes, user_data))
+}
+
+fn normalize_org(raw: &str) -> String {
+ raw.trim().trim_matches('/').split('/').last().unwrap_or("").trim().to_string()
+}
+
+fn build_auth_header(token: &str) -> Result {
+ let encoded = b64.encode(format!(":{token}"));
+ header::HeaderValue::from_str(&format!("Basic {encoded}"))
+ .context("Failed to build Azure DevOps auth header")
+}
+
+fn parse_user_data(value: Option<&header::HeaderValue>) -> AzureDevopsUserData {
+ let Some(value) = value.and_then(|val| val.to_str().ok()) else {
+ return AzureDevopsUserData::default();
+ };
+ let mut parts = value.splitn(2, ':');
+ let user_id = parts.next().map(|item| item.trim().to_string());
+ let email = parts.next().map(|item| item.trim().to_string());
+
+ AzureDevopsUserData {
+ user_id: user_id.filter(|item| !item.is_empty()),
+ email: email.filter(|item| !item.is_empty()),
+ }
+}
+
+async fn fetch_pat_details(
+ client: &Client,
+ organization: &str,
+ auth_header: header::HeaderValue,
+ profile: &AzureDevopsProfile,
+ scopes: &[String],
+) -> Option {
+ let subject_descriptor =
+ fetch_subject_descriptor(client, organization, auth_header.clone(), profile).await?;
+ let mut url = Url::parse(&format!(
+ "https://vssps.dev.azure.com/{organization}/_apis/tokenadmin/personalaccesstokens/"
+ ))
+ .ok()?;
+ url.path_segments_mut().ok()?.push(&subject_descriptor);
+ url.query_pairs_mut().append_pair("api-version", AZURE_DEVOPS_TOKEN_ADMIN_VERSION);
+ let resp = client
+ .get(url)
+ .header(header::ACCEPT, "application/json")
+ .header(header::AUTHORIZATION, auth_header)
+ .send()
+ .await
+ .ok()?;
+
+ if !resp.status().is_success() {
+ return None;
+ }
+
+ let payload: AzureDevopsListResponse = resp.json().await.ok()?;
+ select_matching_pat(&payload.value, scopes, profile.id.as_deref())
+}
+
+async fn fetch_subject_descriptor(
+ client: &Client,
+ organization: &str,
+ auth_header: header::HeaderValue,
+ profile: &AzureDevopsProfile,
+) -> Option {
+ let mut attempts: Vec<(Option<&str>, Option<&str>)> = Vec::new();
+ if let Some(identity_id) = profile.id.as_deref().filter(|value| !value.trim().is_empty()) {
+ attempts.push((Some(identity_id), None));
+ }
+ if let Some(email) = profile.email_address.as_deref().filter(|value| !value.trim().is_empty()) {
+ attempts.push((None, Some(email)));
+ }
+ if let Some(alias) = profile.public_alias.as_deref().filter(|value| !value.trim().is_empty()) {
+ attempts.push((None, Some(alias)));
+ }
+ if let Some(display_name) =
+ profile.display_name.as_deref().filter(|value| !value.trim().is_empty())
+ {
+ attempts.push((None, Some(display_name)));
+ }
+
+ for (identity_id, search_value) in attempts {
+ let mut url =
+ Url::parse(&format!("https://vssps.dev.azure.com/{organization}/_apis/identities"))
+ .ok()?;
+ url.query_pairs_mut()
+ .append_pair("api-version", AZURE_DEVOPS_TOKEN_ADMIN_VERSION)
+ .append_pair("queryMembership", "None");
+ if let Some(identity_id) = identity_id {
+ url.query_pairs_mut().append_pair("identityIds", identity_id);
+ } else if let Some(search_value) = search_value {
+ url.query_pairs_mut()
+ .append_pair("searchFilter", "General")
+ .append_pair("filterValue", search_value);
+ }
+
+ let resp = client
+ .get(url)
+ .header(header::ACCEPT, "application/json")
+ .header(header::AUTHORIZATION, auth_header.clone())
+ .send()
+ .await
+ .ok()?;
+
+ if !resp.status().is_success() {
+ continue;
+ }
+
+ let payload: AzureDevopsListResponse = resp.json().await.ok()?;
+ if let Some(descriptor) = payload
+ .value
+ .into_iter()
+ .filter_map(|identity| identity.subject_descriptor)
+ .find(|value| !value.trim().is_empty())
+ {
+ return Some(descriptor);
+ }
+ }
+
+ None
+}
+
+fn parse_pat_scopes(scope: Option<&str>) -> Vec {
+ scope
+ .map(|value| {
+ value
+ .split_whitespace()
+ .map(|entry| entry.trim().to_string())
+ .filter(|entry| !entry.is_empty())
+ .collect::>()
+ })
+ .unwrap_or_default()
+}
+
+fn select_matching_pat(
+ pats: &[AzureDevopsPat],
+ scopes: &[String],
+ user_id: Option<&str>,
+) -> Option {
+ if pats.is_empty() {
+ return None;
+ }
+
+ let mut candidates: Vec<&AzureDevopsPat> = pats
+ .iter()
+ .filter(|pat| {
+ if let Some(user_id) = user_id {
+ if let Some(pat_user_id) = pat.user_id.as_deref() {
+ return pat_user_id == user_id;
+ }
+ }
+ true
+ })
+ .collect();
+
+ let mut desired_scopes = scopes.to_vec();
+ desired_scopes.sort();
+ desired_scopes.dedup();
+
+ if !desired_scopes.is_empty() {
+ let scope_matches: Vec<&AzureDevopsPat> = candidates
+ .iter()
+ .copied()
+ .filter(|pat| {
+ let mut pat_scopes = parse_pat_scopes(pat.scope.as_deref());
+ pat_scopes.sort();
+ pat_scopes.dedup();
+ if pat_scopes.is_empty() {
+ return false;
+ }
+ pat_scopes == desired_scopes
+ || desired_scopes.iter().all(|scope| pat_scopes.contains(scope))
+ })
+ .collect();
+ if !scope_matches.is_empty() {
+ candidates = scope_matches;
+ }
+ }
+
+ candidates.into_iter().max_by_key(|pat| pat.valid_from.as_deref().unwrap_or_default()).cloned()
+}
+
+async fn list_repositories(
+ client: &Client,
+ organization: &str,
+ auth_header: header::HeaderValue,
+ projects: &[AzureDevopsProject],
+) -> Result> {
+ let url = format!(
+ "https://dev.azure.com/{organization}/_apis/git/repositories?api-version={AZURE_DEVOPS_API_VERSION}"
+ );
+ let resp = client
+ .get(url)
+ .header(header::ACCEPT, "application/json")
+ .header(header::AUTHORIZATION, auth_header.clone())
+ .send()
+ .await
+ .context("Azure DevOps access-map: failed to list repositories")?;
+
+ let mut repos = if resp.status().is_success() {
+ let payload: AzureDevopsListResponse =
+ resp.json().await.context("Azure DevOps access-map: invalid repo JSON")?;
+ payload.value
+ } else {
+ warn!("Azure DevOps access-map: repository enumeration failed with HTTP {}", resp.status());
+ Vec::new()
+ };
+
+ if !repos.is_empty() || projects.is_empty() {
+ return Ok(repos);
+ }
+
+ for project in projects {
+ let project_name = project.name.trim();
+ if project_name.is_empty() {
+ continue;
+ }
+
+ let mut project_repos =
+ list_project_repositories(client, organization, project_name, auth_header.clone())
+ .await
+ .unwrap_or_else(|err| {
+ warn!(
+ "Azure DevOps access-map: project repo enumeration failed for {project_name}: {err}"
+ );
+ Vec::new()
+ });
+ repos.append(&mut project_repos);
+ }
+
+ Ok(repos)
+}
+
+async fn list_project_repositories(
+ client: &Client,
+ organization: &str,
+ project: &str,
+ auth_header: header::HeaderValue,
+) -> Result> {
+ let url = format!(
+ "https://dev.azure.com/{organization}/{project}/_apis/git/repositories?api-version={AZURE_DEVOPS_API_VERSION}"
+ );
+ let resp = client
+ .get(url)
+ .header(header::ACCEPT, "application/json")
+ .header(header::AUTHORIZATION, auth_header)
+ .send()
+ .await
+ .context("Azure DevOps access-map: failed to list project repositories")?;
+
+ if !resp.status().is_success() {
+ return Err(anyhow!(
+ "Azure DevOps access-map: project repository enumeration failed with HTTP {}",
+ resp.status()
+ ));
+ }
+
+ let payload: AzureDevopsListResponse =
+ resp.json().await.context("Azure DevOps access-map: invalid repo JSON")?;
+ Ok(payload.value)
+}
+
+async fn list_projects(
+ client: &Client,
+ organization: &str,
+ auth_header: header::HeaderValue,
+) -> Result> {
+ let url = format!(
+ "https://dev.azure.com/{organization}/_apis/projects?api-version={AZURE_DEVOPS_API_VERSION}"
+ );
+ let resp = client
+ .get(url)
+ .header(header::ACCEPT, "application/json")
+ .header(header::AUTHORIZATION, auth_header)
+ .send()
+ .await
+ .context("Azure DevOps access-map: failed to list projects")?;
+
+ if !resp.status().is_success() {
+ warn!("Azure DevOps access-map: project enumeration failed with HTTP {}", resp.status());
+ return Ok(Vec::new());
+ }
+
+ let payload: AzureDevopsListResponse =
+ resp.json().await.context("Azure DevOps access-map: invalid project JSON")?;
+ Ok(payload.value)
+}
+
+fn derive_severity(projects: &[AzureDevopsProject], repos: &[AzureDevopsRepo]) -> Severity {
+ if !repos.is_empty()
+ || projects.iter().any(|project| {
+ project
+ .visibility
+ .as_deref()
+ .map(|v| v.eq_ignore_ascii_case("private"))
+ .unwrap_or(false)
+ })
+ {
+ Severity::Medium
+ } else {
+ Severity::Low
+ }
+}
+
+fn severity_to_str(severity: Severity) -> &'static str {
+ match severity {
+ Severity::Low => "low",
+ Severity::Medium => "medium",
+ Severity::High => "high",
+ Severity::Critical => "critical",
+ }
+}
diff --git a/src/access_map/gcp.rs b/src/access_map/gcp.rs
index 517142e..119721d 100644
--- a/src/access_map/gcp.rs
+++ b/src/access_map/gcp.rs
@@ -199,6 +199,8 @@ pub async fn map_access_from_json(data: &str) -> Result {
severity,
recommendations,
risk_notes,
+ token_details: None,
+ provider_metadata: None,
})
}
diff --git a/src/access_map/github.rs b/src/access_map/github.rs
new file mode 100644
index 0000000..19e2fd9
--- /dev/null
+++ b/src/access_map/github.rs
@@ -0,0 +1,420 @@
+use anyhow::{anyhow, Context, Result};
+use reqwest::{header, Client, Url};
+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 DEFAULT_GITHUB_API: &str = "https://api.github.com";
+
+#[derive(Deserialize)]
+struct GitHubUser {
+ login: String,
+ id: u64,
+ #[serde(default)]
+ name: Option,
+ #[serde(default)]
+ email: Option,
+ #[serde(default)]
+ company: Option,
+ #[serde(default)]
+ location: Option,
+ #[serde(default)]
+ html_url: Option,
+ #[serde(default)]
+ r#type: String,
+}
+
+#[derive(Deserialize)]
+struct GitHubRepo {
+ full_name: String,
+ private: bool,
+ permissions: Option,
+}
+
+#[derive(Deserialize)]
+struct GitHubOrg {
+ login: String,
+}
+
+#[derive(Deserialize)]
+struct GitHubOrgMembership {
+ organization: GitHubOrg,
+ #[serde(default)]
+ role: String,
+ #[serde(default)]
+ state: String,
+}
+
+#[derive(Clone, Deserialize)]
+struct GitHubRepoPermissions {
+ admin: bool,
+ push: bool,
+ pull: bool,
+}
+
+pub async fn map_access(args: &AccessMapArgs) -> Result {
+ 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 GitHub token from {}", path.display()))?;
+ raw.trim().to_string()
+ } else {
+ return Err(anyhow!("GitHub 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 {
+ let api_url = Url::parse(DEFAULT_GITHUB_API).expect("valid GitHub API URL");
+ let client = Client::builder()
+ .user_agent(GLOBAL_USER_AGENT.as_str())
+ .build()
+ .context("Failed to build GitHub HTTP client")?;
+
+ let user_resp = client
+ .get(api_url.join("user")?)
+ .header(header::AUTHORIZATION, format!("token {token}"))
+ .header(header::ACCEPT, "application/vnd.github+json")
+ .send()
+ .await
+ .context("GitHub access-map: failed to fetch user info")?;
+
+ if !user_resp.status().is_success() {
+ return Err(anyhow!(
+ "GitHub access-map: user lookup failed with HTTP {}",
+ user_resp.status()
+ ));
+ }
+
+ let oauth_scopes = parse_csv_header(user_resp.headers().get("x-oauth-scopes"));
+ let token_expiration = user_resp
+ .headers()
+ .get("github-authentication-token-expiration")
+ .and_then(|val| val.to_str().ok())
+ .map(|value| value.trim().to_string())
+ .filter(|value| !value.is_empty());
+ let token_type = user_resp
+ .headers()
+ .get("github-authentication-token-type")
+ .and_then(|val| val.to_str().ok())
+ .map(|value| value.trim().to_string())
+ .filter(|value| !value.is_empty());
+
+ let user: GitHubUser =
+ user_resp.json().await.context("GitHub access-map: invalid user JSON")?;
+
+ let identity = AccessSummary {
+ id: user.login.clone(),
+ access_type: if user.r#type.is_empty() {
+ "user".into()
+ } else {
+ user.r#type.to_lowercase()
+ },
+ project: None,
+ tenant: None,
+ account_id: None,
+ };
+
+ let repos = list_accessible_repos(&client, &api_url, token).await?;
+ let mut risk_notes = Vec::new();
+ let mut resources = Vec::new();
+ let mut permissions = PermissionSummary::default();
+
+ let org_scopes = org_scopes(&oauth_scopes);
+ let org_memberships =
+ list_org_memberships(&client, &api_url, token).await.unwrap_or_else(|err| {
+ warn!("GitHub access-map: org membership lookup failed: {err}");
+ Vec::new()
+ });
+
+ for membership in org_memberships.into_iter().filter(|m| m.state == "active") {
+ let mut org_permissions = org_scopes.clone();
+ if !membership.role.trim().is_empty() {
+ org_permissions.push(format!("org_role:{}", membership.role.trim()));
+ }
+ org_permissions.sort();
+ org_permissions.dedup();
+ if org_permissions.is_empty() {
+ continue;
+ }
+
+ let risk = if org_permissions.iter().any(|perm| perm.contains("admin")) {
+ Severity::High
+ } else if org_permissions.iter().any(|perm| perm.contains("write")) {
+ Severity::Medium
+ } else {
+ Severity::Low
+ };
+
+ resources.push(ResourceExposure {
+ resource_type: "organization".into(),
+ name: membership.organization.login,
+ permissions: org_permissions.clone(),
+ risk: severity_to_str(risk).to_string(),
+ reason: "Organization membership available to the token".into(),
+ });
+ }
+
+ for repo in &repos {
+ let perms = repo.permissions.clone().unwrap_or(GitHubRepoPermissions {
+ admin: false,
+ push: false,
+ pull: true,
+ });
+
+ let mut repo_perms = Vec::new();
+ if perms.admin {
+ repo_perms.push("repo:admin".to_string());
+ }
+ if perms.push {
+ repo_perms.push("repo:write".to_string());
+ }
+ if perms.pull {
+ repo_perms.push("repo:read".to_string());
+ }
+
+ let risk = if perms.admin {
+ Severity::High
+ } else if perms.push {
+ Severity::Medium
+ } else {
+ Severity::Low
+ };
+
+ let reason = if repo.private {
+ "Accessible private repository".to_string()
+ } else {
+ "Accessible public repository".to_string()
+ };
+
+ resources.push(ResourceExposure {
+ resource_type: "repository".into(),
+ name: repo.full_name.clone(),
+ permissions: repo_perms.clone(),
+ risk: severity_to_str(risk).to_string(),
+ reason,
+ });
+
+ if perms.admin {
+ permissions.admin.push("repo:admin".to_string());
+ } else if perms.push {
+ permissions.risky.push("repo:write".to_string());
+ } else if perms.pull {
+ permissions.read_only.push("repo:read".to_string());
+ }
+ }
+
+ permissions.admin.sort();
+ permissions.admin.dedup();
+ permissions.risky.sort();
+ permissions.risky.dedup();
+ permissions.read_only.sort();
+ permissions.read_only.dedup();
+
+ let severity = derive_severity(&repos);
+
+ let mut roles = Vec::new();
+ if !oauth_scopes.is_empty() {
+ roles.push(RoleBinding {
+ name: "token_scopes".into(),
+ source: "github".into(),
+ permissions: oauth_scopes.clone(),
+ });
+ }
+
+ if repos.is_empty() {
+ resources.push(ResourceExposure {
+ resource_type: "account".into(),
+ name: user.login.clone(),
+ permissions: Vec::new(),
+ risk: severity_to_str(Severity::Low).to_string(),
+ reason: "GitHub account associated with the token".into(),
+ });
+ risk_notes.push("Token did not enumerate any repositories".into());
+ }
+
+ if roles.is_empty() {
+ risk_notes.push(
+ "GitHub did not report OAuth scopes; fine-grained tokens may omit scope headers".into(),
+ );
+ }
+
+ let user_display_name = user
+ .name
+ .clone()
+ .filter(|value| !value.trim().is_empty())
+ .or_else(|| Some(user.login.clone()));
+ let user_identifier = if let Some(email) = user.email.as_ref().filter(|v| !v.trim().is_empty())
+ {
+ format!("{} ({email})", user.login)
+ } else {
+ user.login.clone()
+ };
+
+ Ok(AccessMapResult {
+ cloud: "github".into(),
+ identity,
+ roles,
+ permissions,
+ resources,
+ severity,
+ recommendations: build_recommendations(severity),
+ risk_notes,
+ token_details: Some(AccessTokenDetails {
+ name: user_display_name,
+ username: Some(user.login.clone()),
+ account_type: Some(user.r#type.clone()).filter(|value| !value.trim().is_empty()),
+ company: user.company.clone().filter(|value| !value.trim().is_empty()),
+ location: user.location.clone().filter(|value| !value.trim().is_empty()),
+ email: user.email.clone().filter(|value| !value.trim().is_empty()),
+ url: user.html_url.clone().filter(|value| !value.trim().is_empty()),
+ token_type,
+ created_at: None,
+ last_used_at: None,
+ expires_at: token_expiration,
+ user_id: Some(user_identifier),
+ scopes: oauth_scopes.clone(),
+ }),
+ provider_metadata: None,
+ })
+}
+
+fn parse_csv_header(value: Option<&header::HeaderValue>) -> Vec {
+ value
+ .and_then(|val| val.to_str().ok())
+ .map(|scopes| {
+ scopes
+ .split(',')
+ .map(|scope| scope.trim().to_string())
+ .filter(|scope| !scope.is_empty())
+ .collect::>()
+ })
+ .unwrap_or_default()
+}
+
+async fn list_accessible_repos(
+ client: &Client,
+ api_url: &Url,
+ token: &str,
+) -> Result> {
+ let mut repos = Vec::new();
+ let mut page = 1u32;
+ let per_page = 100u32;
+
+ loop {
+ let mut url = api_url.join("user/repos")?;
+ url.query_pairs_mut()
+ .append_pair("per_page", &per_page.to_string())
+ .append_pair("page", &page.to_string());
+
+ let resp = client
+ .get(url)
+ .header(header::AUTHORIZATION, format!("token {token}"))
+ .header(header::ACCEPT, "application/vnd.github+json")
+ .send()
+ .await
+ .context("GitHub access-map: failed to list repositories")?;
+
+ if !resp.status().is_success() {
+ warn!("GitHub access-map: repo enumeration failed with HTTP {}", resp.status());
+ break;
+ }
+
+ let mut page_repos: Vec =
+ resp.json().await.context("GitHub access-map: invalid repository JSON")?;
+ let count = page_repos.len();
+ repos.append(&mut page_repos);
+
+ if count < per_page as usize {
+ break;
+ }
+ page += 1;
+ }
+
+ Ok(repos)
+}
+
+async fn list_org_memberships(
+ client: &Client,
+ api_url: &Url,
+ token: &str,
+) -> Result> {
+ let mut orgs = Vec::new();
+ let mut page = 1u32;
+ let per_page = 100u32;
+
+ loop {
+ let mut url = api_url.join("user/memberships/orgs")?;
+ url.query_pairs_mut()
+ .append_pair("per_page", &per_page.to_string())
+ .append_pair("page", &page.to_string());
+
+ let resp = client
+ .get(url)
+ .header(header::AUTHORIZATION, format!("token {token}"))
+ .header(header::ACCEPT, "application/vnd.github+json")
+ .send()
+ .await
+ .context("GitHub access-map: failed to list org memberships")?;
+
+ if !resp.status().is_success() {
+ warn!(
+ "GitHub access-map: org membership enumeration failed with HTTP {}",
+ resp.status()
+ );
+ break;
+ }
+
+ let mut page_orgs: Vec =
+ resp.json().await.context("GitHub access-map: invalid org JSON")?;
+ let count = page_orgs.len();
+ orgs.append(&mut page_orgs);
+
+ if count < per_page as usize {
+ break;
+ }
+ page += 1;
+ }
+
+ Ok(orgs)
+}
+
+fn derive_severity(repos: &[GitHubRepo]) -> Severity {
+ let mut severity = Severity::Low;
+ for repo in repos {
+ let perms = repo.permissions.as_ref();
+ if perms.map_or(false, |p| p.admin) {
+ return Severity::High;
+ }
+ if perms.map_or(false, |p| p.push) {
+ severity = Severity::Medium;
+ }
+ }
+ severity
+}
+
+fn org_scopes(scopes: &[String]) -> Vec {
+ let mut result: Vec = scopes
+ .iter()
+ .filter(|scope| scope.contains(":org") || scope.contains(":enterprise"))
+ .cloned()
+ .collect();
+ result.sort();
+ result.dedup();
+ result
+}
+
+fn severity_to_str(severity: Severity) -> &'static str {
+ match severity {
+ Severity::Low => "low",
+ Severity::Medium => "medium",
+ Severity::High => "high",
+ Severity::Critical => "critical",
+ }
+}
diff --git a/src/access_map/gitlab.rs b/src/access_map/gitlab.rs
new file mode 100644
index 0000000..6798d6a
--- /dev/null
+++ b/src/access_map/gitlab.rs
@@ -0,0 +1,309 @@
+use anyhow::{anyhow, Context, Result};
+use reqwest::{header, Client, Url};
+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,
+ ProviderMetadata, ResourceExposure, RoleBinding, Severity,
+};
+
+const DEFAULT_GITLAB_API: &str = "https://gitlab.com/api/v4/";
+
+#[derive(Deserialize)]
+struct GitLabProject {
+ path_with_namespace: String,
+ visibility: String,
+ permissions: Option,
+}
+
+#[derive(Clone, Deserialize)]
+struct GitLabProjectPermissions {
+ project_access: Option,
+ group_access: Option,
+}
+
+#[derive(Clone, Deserialize)]
+struct GitLabAccess {
+ access_level: u32,
+}
+
+#[derive(Deserialize)]
+struct GitLabTokenInfo {
+ _id: Option,
+ name: Option,
+ created_at: Option,
+ last_used_at: Option,
+ expires_at: Option,
+ scopes: Option>,
+ user_id: Option,
+}
+
+#[derive(Deserialize)]
+struct GitLabMetadata {
+ version: Option,
+ enterprise: Option,
+}
+
+pub async fn map_access(args: &AccessMapArgs) -> Result {
+ 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 GitLab token from {}", path.display()))?;
+ raw.trim().to_string()
+ } else {
+ return Err(anyhow!("GitLab 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 {
+ let api_url = Url::parse(DEFAULT_GITLAB_API).expect("valid GitLab API URL");
+ let client = Client::builder()
+ .user_agent(GLOBAL_USER_AGENT.as_str())
+ .build()
+ .context("Failed to build GitLab HTTP client")?;
+
+ let token_info = fetch_token_info(&client, &api_url, token).await;
+ let identity_label = token_info
+ .as_ref()
+ .and_then(|info| info.name.clone())
+ .or_else(|| {
+ token_info
+ .as_ref()
+ .and_then(|info| info.user_id)
+ .map(|user_id| format!("gitlab_user_{user_id}"))
+ })
+ .unwrap_or_else(|| "gitlab_token".to_string());
+
+ let identity = AccessSummary {
+ id: identity_label,
+ access_type: "token".into(),
+ project: None,
+ tenant: None,
+ account_id: None,
+ };
+
+ let scopes = token_info.as_ref().and_then(|info| info.scopes.clone());
+ let projects = list_accessible_projects(&client, &api_url, token).await?;
+ let metadata = fetch_instance_metadata(&client, &api_url, token).await;
+ let mut risk_notes = Vec::new();
+ let mut resources = Vec::new();
+ let mut permissions = PermissionSummary::default();
+
+ for project in &projects {
+ let access_level =
+ project.permissions.as_ref().map(effective_access_level).unwrap_or_default();
+ let (perm_label, severity) = access_level_to_risk(access_level);
+
+ resources.push(ResourceExposure {
+ resource_type: "project".into(),
+ name: project.path_with_namespace.clone(),
+ permissions: vec![perm_label.to_string()],
+ risk: severity_to_str(severity).to_string(),
+ reason: format!("Accessible {} project", project.visibility),
+ });
+
+ match severity {
+ Severity::High | Severity::Critical => permissions.admin.push(perm_label.to_string()),
+ Severity::Medium => permissions.risky.push(perm_label.to_string()),
+ Severity::Low => permissions.read_only.push(perm_label.to_string()),
+ }
+ }
+
+ permissions.admin.sort();
+ permissions.admin.dedup();
+ permissions.risky.sort();
+ permissions.risky.dedup();
+ permissions.read_only.sort();
+ permissions.read_only.dedup();
+
+ let severity = derive_severity(&projects);
+
+ let mut roles = Vec::new();
+ if let Some(ref scopes) = scopes {
+ if !scopes.is_empty() {
+ roles.push(RoleBinding {
+ name: "token_scopes".into(),
+ source: "gitlab".into(),
+ permissions: scopes.clone(),
+ });
+ }
+ }
+
+ if projects.is_empty() {
+ resources.push(ResourceExposure {
+ resource_type: "account".into(),
+ name: token_info
+ .as_ref()
+ .and_then(|info| info.name.clone())
+ .unwrap_or_else(|| identity.id.clone()),
+ permissions: Vec::new(),
+ risk: severity_to_str(Severity::Low).to_string(),
+ reason: "GitLab account associated with the token".into(),
+ });
+ risk_notes.push("Token did not enumerate any projects".into());
+ }
+
+ if roles.is_empty() {
+ risk_notes.push("GitLab did not report token scopes".into());
+ }
+
+ let token_details = token_info.as_ref().map(|info| AccessTokenDetails {
+ name: info.name.clone(),
+ username: None,
+ account_type: None,
+ company: None,
+ location: None,
+ email: None,
+ url: None,
+ token_type: None,
+ created_at: info.created_at.clone(),
+ last_used_at: info.last_used_at.clone(),
+ expires_at: info.expires_at.clone(),
+ user_id: info.user_id.map(|user_id| user_id.to_string()),
+ scopes: scopes.clone().unwrap_or_default(),
+ });
+
+ Ok(AccessMapResult {
+ cloud: "gitlab".into(),
+ identity,
+ roles,
+ permissions,
+ resources,
+ severity,
+ recommendations: build_recommendations(severity),
+ risk_notes,
+ token_details,
+ provider_metadata: metadata
+ .map(|info| ProviderMetadata { version: info.version, enterprise: info.enterprise }),
+ })
+}
+
+async fn fetch_token_info(client: &Client, api_url: &Url, token: &str) -> Option {
+ let resp = client
+ .get(api_url.join("personal_access_tokens/self").ok()?)
+ .header("PRIVATE-TOKEN", token)
+ .header(header::ACCEPT, "application/json")
+ .send()
+ .await
+ .ok()?;
+
+ if !resp.status().is_success() {
+ return None;
+ }
+
+ resp.json().await.ok()
+}
+
+async fn fetch_instance_metadata(
+ client: &Client,
+ api_url: &Url,
+ token: &str,
+) -> Option {
+ let resp = client
+ .get(api_url.join("metadata").ok()?)
+ .header("PRIVATE-TOKEN", token)
+ .header(header::ACCEPT, "application/json")
+ .send()
+ .await
+ .ok()?;
+
+ if !resp.status().is_success() {
+ return None;
+ }
+
+ resp.json().await.ok()
+}
+
+async fn list_accessible_projects(
+ client: &Client,
+ api_url: &Url,
+ token: &str,
+) -> Result> {
+ let mut projects = Vec::new();
+ let mut page = 1u32;
+ let per_page = 100u32;
+
+ loop {
+ let mut url = api_url.join("projects")?;
+ url.query_pairs_mut()
+ .append_pair("min_access_level", "10")
+ .append_pair("per_page", &per_page.to_string())
+ .append_pair("page", &page.to_string());
+
+ let resp = client
+ .get(url)
+ .header("PRIVATE-TOKEN", token)
+ .header(header::ACCEPT, "application/json")
+ .send()
+ .await
+ .context("GitLab access-map: failed to list projects")?;
+
+ if !resp.status().is_success() {
+ warn!("GitLab access-map: project enumeration failed with HTTP {}", resp.status());
+ break;
+ }
+
+ let next_page = resp
+ .headers()
+ .get("x-next-page")
+ .and_then(|value| value.to_str().ok())
+ .and_then(|value| value.parse::().ok());
+
+ let mut page_projects: Vec =
+ resp.json().await.context("GitLab access-map: invalid project JSON")?;
+ let count = page_projects.len();
+ projects.append(&mut page_projects);
+
+ if count < per_page as usize || next_page.is_none() {
+ break;
+ }
+ page = next_page.unwrap_or(page + 1);
+ }
+
+ Ok(projects)
+}
+
+fn effective_access_level(perms: &GitLabProjectPermissions) -> u32 {
+ let project_level = perms.project_access.as_ref().map(|access| access.access_level);
+ let group_level = perms.group_access.as_ref().map(|access| access.access_level);
+ project_level.max(group_level).unwrap_or_default()
+}
+
+fn access_level_to_risk(access_level: u32) -> (&'static str, Severity) {
+ match access_level {
+ 50 => ("project:owner", Severity::High),
+ 40 => ("project:maintainer", Severity::High),
+ 30 => ("project:developer", Severity::Medium),
+ 20 => ("project:reporter", Severity::Low),
+ 10 => ("project:guest", Severity::Low),
+ _ => ("project:access", Severity::Low),
+ }
+}
+
+fn derive_severity(projects: &[GitLabProject]) -> Severity {
+ let mut severity = Severity::Low;
+ for project in projects {
+ let access_level =
+ project.permissions.as_ref().map(effective_access_level).unwrap_or_default();
+ let (_, project_severity) = access_level_to_risk(access_level);
+ match project_severity {
+ Severity::High | Severity::Critical => return Severity::High,
+ Severity::Medium => severity = Severity::Medium,
+ Severity::Low => {}
+ }
+ }
+ severity
+}
+
+fn severity_to_str(severity: Severity) -> &'static str {
+ match severity {
+ Severity::Low => "low",
+ Severity::Medium => "medium",
+ Severity::High => "high",
+ Severity::Critical => "critical",
+ }
+}
diff --git a/src/cli/commands/access_map.rs b/src/cli/commands/access_map.rs
index 94b8a0f..4b62a47 100644
--- a/src/cli/commands/access_map.rs
+++ b/src/cli/commands/access_map.rs
@@ -5,7 +5,7 @@ use clap::{Args, ValueEnum};
/// Inspect a cloud credential and derive the effective identity and blast radius.
#[derive(Args, Debug)]
pub struct AccessMapArgs {
- /// Cloud provider: aws | gcp | azure
+ /// Cloud provider: aws | gcp | azure | github | gitlab
#[clap(value_parser, value_name = "PROVIDER")]
pub provider: AccessMapProvider,
@@ -31,4 +31,8 @@ pub enum AccessMapProvider {
Gcp,
/// Microsoft Azure
Azure,
+ /// GitHub
+ Github,
+ /// GitLab
+ Gitlab,
}
diff --git a/src/cli/commands/inputs.rs b/src/cli/commands/inputs.rs
index f81c625..ab72881 100644
--- a/src/cli/commands/inputs.rs
+++ b/src/cli/commands/inputs.rs
@@ -35,6 +35,22 @@ pub struct InputSpecifierArgs {
#[arg(long, value_hint = ValueHint::Url)]
pub git_url: Vec,
+ /// Parent directory for cloned Git repositories and scan artifacts
+ #[arg(long = "git-clone-dir", value_hint = ValueHint::DirPath, help_heading = "Git Options")]
+ pub git_clone_dir: Option,
+
+ /// Keep cloned Git repositories on disk after the scan completes
+ #[arg(long = "keep-clones", default_value_t = false, help_heading = "Git Options")]
+ pub keep_clones: bool,
+
+ /// Limit the number of GitHub/GitLab repositories cloned during enumeration
+ #[arg(long = "repo-clone-limit", value_name = "COUNT")]
+ pub repo_clone_limit: Option,
+
+ /// Include contributor repositories when scanning GitHub or GitLab git URLs
+ #[arg(long = "include-contributors", default_value_t = false)]
+ pub include_contributors: bool,
+
/// Scan repositories belonging to the specified GitHub user
#[arg(long, hide = true)]
pub github_user: Vec,
diff --git a/src/cli/commands/scan.rs b/src/cli/commands/scan.rs
index f005d62..22f2ee0 100644
--- a/src/cli/commands/scan.rs
+++ b/src/cli/commands/scan.rs
@@ -82,6 +82,26 @@ pub struct ScanArgs {
#[arg(global = true, long, short = 'n', default_value_t = false)]
pub no_validate: bool,
+ /// Timeout for validation requests in seconds (1-60)
+ #[arg(
+ global = true,
+ long = "validation-timeout",
+ default_value_t = 10,
+ value_name = "SECONDS",
+ value_parser = clap::value_parser!(u64).range(1..=60)
+ )]
+ pub validation_timeout: u64,
+
+ /// Number of retries for validation requests (0-5)
+ #[arg(
+ global = true,
+ long = "validation-retries",
+ default_value_t = 1,
+ value_name = "N",
+ value_parser = clap::value_parser!(u32).range(0..=5)
+ )]
+ pub validation_retries: u32,
+
/// Map validated cloud credentials to their effective identities; use only when
/// authorized for the target account because this triggers additional network
/// requests to determine granted access
@@ -107,6 +127,10 @@ pub struct ScanArgs {
#[arg(global = true, long, default_value_t = false)]
pub no_dedup: bool,
+ /// Serve a JSON report locally and open the browser (http://127.0.0.1:7890)
+ #[arg(skip)]
+ pub view_report: bool,
+
/// Redact findings values using a secure hash
#[arg(global = true, long, short = 'r', default_value_t = false)]
pub redact: bool,
@@ -188,6 +212,10 @@ pub struct ScanCommandArgs {
#[command(flatten)]
pub scan_args: ScanArgs,
+ /// Serve a JSON report locally and open the browser (http://127.0.0.1:7890)
+ #[arg(global = true, long = "view-report", default_value_t = false)]
+ pub view_report: bool,
+
#[command(subcommand)]
pub provider: Option,
}
@@ -213,6 +241,8 @@ impl ScanCommandArgs {
pub fn into_operation(mut self) -> anyhow::Result {
let mut used_provider_subcommand = false;
+ self.scan_args.view_report = self.view_report;
+
if let Some(provider) = self.provider.take() {
used_provider_subcommand = true;
let scan_args = &mut self.scan_args;
@@ -246,6 +276,9 @@ impl ScanCommandArgs {
args.specifiers.all_organizations;
scan_args.input_specifier_args.github_repo_type = args.specifiers.repo_type;
scan_args.input_specifier_args.github_api_url = args.api_url;
+ scan_args.input_specifier_args.repo_clone_limit = args.repo_clone_limit;
+ scan_args.input_specifier_args.include_contributors =
+ args.include_contributors;
None
}
}
@@ -271,6 +304,9 @@ impl ScanCommandArgs {
args.specifiers.include_subgroups;
scan_args.input_specifier_args.gitlab_repo_type = args.specifiers.repo_type;
scan_args.input_specifier_args.gitlab_api_url = args.api_url;
+ scan_args.input_specifier_args.repo_clone_limit = args.repo_clone_limit;
+ scan_args.input_specifier_args.include_contributors =
+ args.include_contributors;
None
}
}
@@ -505,6 +541,14 @@ pub struct GithubScanArgs {
#[command(flatten)]
pub specifiers: GitHubRepoSpecifiers,
+ /// Include contributor repositories when scanning git URLs
+ #[arg(long = "include-contributors", default_value_t = false)]
+ pub include_contributors: bool,
+
+ /// Limit the number of repositories cloned (including contributor repos)
+ #[arg(long = "repo-clone-limit", value_name = "COUNT")]
+ pub repo_clone_limit: Option,
+
/// List matching repositories without scanning them
#[arg(long = "list-only")]
pub list_only: bool,
@@ -524,6 +568,14 @@ pub struct GitLabScanArgs {
#[command(flatten)]
pub specifiers: GitLabRepoSpecifiers,
+ /// Include contributor repositories when scanning git URLs
+ #[arg(long = "include-contributors", default_value_t = false)]
+ pub include_contributors: bool,
+
+ /// Limit the number of repositories cloned (including contributor repos)
+ #[arg(long = "repo-clone-limit", value_name = "COUNT")]
+ pub repo_clone_limit: Option,
+
/// List matching repositories without scanning them
#[arg(long = "list-only")]
pub list_only: bool,
diff --git a/src/cli/commands/view.rs b/src/cli/commands/view.rs
index 2598550..4d1ca27 100644
--- a/src/cli/commands/view.rs
+++ b/src/cli/commands/view.rs
@@ -13,24 +13,29 @@ use axum::{
routing::get,
Router,
};
-use clap::ValueHint;
use include_dir::{include_dir, Dir};
use tokio::net::TcpListener;
-use tracing::info;
+use tracing::{info, warn};
-const DEFAULT_PORT: u16 = 7890;
+pub const DEFAULT_PORT: u16 = 7890;
static VIEWER_ASSETS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/docs/access-map-viewer");
/// View a Kingfisher access-map report locally.
#[derive(clap::Args, Debug)]
pub struct ViewArgs {
/// Path to a JSON or JSONL access-map report to load automatically
- #[arg(value_name = "REPORT", value_hint = ValueHint::FilePath)]
+ #[arg(value_name = "REPORT", value_hint = clap::ValueHint::FilePath)]
pub report: Option,
/// Local port for the embedded viewer (default 7890)
#[arg(long, default_value_t = DEFAULT_PORT)]
pub port: u16,
+
+ #[arg(skip)]
+ pub open_browser: bool,
+
+ #[arg(skip)]
+ pub report_bytes: Option>,
}
#[derive(Clone)]
@@ -40,7 +45,9 @@ struct AppState {
/// Run the `kingfisher view` subcommand.
pub async fn run(args: ViewArgs) -> Result<()> {
- let report = if let Some(path) = args.report.as_ref() {
+ let report = if let Some(report_bytes) = args.report_bytes.as_ref() {
+ Some(report_bytes.clone())
+ } else if let Some(path) = args.report.as_ref() {
let expanded_path = expand_tilde(path)?;
let ext = path
.extension()
@@ -73,12 +80,20 @@ pub async fn run(args: ViewArgs) -> Result<()> {
let address: SocketAddr =
listener.local_addr().context("Failed to read local listener address")?;
+ let url = format!("http://{}:{}", address.ip(), address.port());
+
info!(%address, "Starting access-map viewer");
- eprintln!(
- "Serving access-map viewer at http://{}:{} (Ctrl+C to stop)",
- address.ip(),
- address.port()
- );
+ eprintln!("Serving access-map viewer at {} (Ctrl+C to stop)", url);
+
+ let open_browser = args.open_browser || args.report.is_some() || args.report_bytes.is_some();
+ if open_browser {
+ let url = url.clone();
+ tokio::task::spawn_blocking(move || {
+ if let Err(err) = webbrowser::open(&url) {
+ warn!(%err, "Failed to open browser for access-map viewer");
+ }
+ });
+ }
let state = Arc::new(AppState { report });
diff --git a/src/gitea.rs b/src/gitea.rs
index a5a5def..90a00ff 100644
--- a/src/gitea.rs
+++ b/src/gitea.rs
@@ -428,7 +428,7 @@ mod tests {
Some("owner/repo")
);
assert_eq!(
- parse_excluded_repo("ssh://git@example.com:3000/Owner/Repo.git").as_deref(),
+ parse_excluded_repo("ssh://git@exmple.com:3000/Owner/Repo.git").as_deref(),
Some("owner/repo")
);
}
diff --git a/src/github.rs b/src/github.rs
index 77105c3..092095c 100644
--- a/src/github.rs
+++ b/src/github.rs
@@ -14,13 +14,25 @@ use octorust::{
types::{Order, ReposListOrgSort, ReposListOrgType, ReposListUserType},
Client,
};
+use reqwest::StatusCode;
+use serde::Deserialize;
use serde_json::Value;
-use tracing::warn;
+use tracing::{info, warn};
use url::Url;
use crate::{findings_store, git_url::GitUrl, validation::GLOBAL_USER_AGENT};
use std::str::FromStr;
+#[derive(Deserialize)]
+struct GitHubContributor {
+ login: Option,
+}
+
+#[derive(Deserialize)]
+struct GitHubRepo {
+ clone_url: String,
+}
+
#[derive(Debug)]
pub struct RepoSpecifiers {
pub user: Vec,
@@ -214,6 +226,185 @@ fn create_github_client(github_url: &url::Url, ignore_certs: bool) -> Result Url {
+ let mut base = api_url.clone();
+ if !base.path().ends_with('/') {
+ let path = format!("{}/", base.path());
+ base.set_path(&path);
+ }
+ base
+}
+
+pub async fn enumerate_contributor_repo_urls(
+ repo_url: &GitUrl,
+ github_api_url: &Url,
+ ignore_certs: bool,
+ exclude_repos: &[String],
+ repo_clone_limit: Option,
+ progress_enabled: bool,
+) -> Result> {
+ let (_, owner, repo) = parse_repo(repo_url).context("invalid GitHub repo URL")?;
+ let exclude_set = build_exclude_matcher(exclude_repos);
+ let client = reqwest::Client::builder().danger_accept_invalid_certs(ignore_certs).build()?;
+ let token = env::var("KF_GITHUB_TOKEN").ok().filter(|t| !t.is_empty());
+ let api_base = normalize_api_base(github_api_url);
+
+ let mut contributor_logins = Vec::new();
+ let mut seen_contributors = HashSet::new();
+ let mut page = 1;
+ loop {
+ let mut url = api_base
+ .join(&format!("repos/{owner}/{repo}/contributors"))
+ .context("Failed to build GitHub contributors URL")?;
+ url.query_pairs_mut().append_pair("per_page", "100").append_pair("page", &page.to_string());
+ let mut req = client.get(url).header("User-Agent", GLOBAL_USER_AGENT.as_str());
+ if let Some(token) = token.as_ref() {
+ req = req.bearer_auth(token);
+ }
+ let resp = req.send().await?;
+ if !resp.status().is_success() {
+ warn_on_rate_limit("GitHub", resp.status(), "listing contributors");
+ break;
+ }
+ let contributors: Vec = resp.json().await?;
+ if contributors.is_empty() {
+ break;
+ }
+ for contributor in contributors {
+ if let Some(login) = contributor.login {
+ if seen_contributors.insert(login.clone()) {
+ contributor_logins.push(login);
+ }
+ }
+ }
+ page += 1;
+ }
+
+ let (per_user_limit, total_limit) =
+ determine_contributor_repo_limits(repo_clone_limit, contributor_logins.len(), "GitHub");
+ let progress = build_contributor_progress_bar(
+ progress_enabled,
+ contributor_logins.len() as u64,
+ "Enumerating GitHub contributor repositories...",
+ );
+
+ let mut repo_urls = Vec::new();
+ let mut total_repo_count = 0usize;
+ for login in contributor_logins {
+ if let Some(total_limit) = total_limit {
+ if total_repo_count >= total_limit {
+ break;
+ }
+ }
+ let mut user_repo_count = 0usize;
+ page = 1;
+ loop {
+ if let Some(per_user_limit) = per_user_limit {
+ if user_repo_count >= per_user_limit {
+ break;
+ }
+ }
+ if let Some(total_limit) = total_limit {
+ if total_repo_count >= total_limit {
+ break;
+ }
+ }
+ let mut url = api_base
+ .join(&format!("users/{login}/repos"))
+ .context("Failed to build GitHub user repos URL")?;
+ url.query_pairs_mut()
+ .append_pair("per_page", "100")
+ .append_pair("page", &page.to_string())
+ .append_pair("type", "all")
+ .append_pair("sort", "updated")
+ .append_pair("direction", "desc");
+ let mut req = client.get(url).header("User-Agent", GLOBAL_USER_AGENT.as_str());
+ if let Some(token) = token.as_ref() {
+ req = req.bearer_auth(token);
+ }
+ let resp = req.send().await?;
+ if !resp.status().is_success() {
+ warn_on_rate_limit("GitHub", resp.status(), "listing user repositories");
+ break;
+ }
+ let repos: Vec = resp.json().await?;
+ if repos.is_empty() {
+ break;
+ }
+ for repo in repos {
+ if let Some(per_user_limit) = per_user_limit {
+ if user_repo_count >= per_user_limit {
+ break;
+ }
+ }
+ if let Some(total_limit) = total_limit {
+ if total_repo_count >= total_limit {
+ break;
+ }
+ }
+ if should_exclude_repo(&repo.clone_url, &exclude_set) {
+ continue;
+ }
+ repo_urls.push(repo.clone_url);
+ user_repo_count += 1;
+ total_repo_count += 1;
+ }
+ page += 1;
+ }
+ progress.inc(1);
+ }
+
+ repo_urls.sort();
+ repo_urls.dedup();
+ progress.finish_and_clear();
+ Ok(repo_urls)
+}
+
+fn warn_on_rate_limit(service: &str, status: StatusCode, action: &str) {
+ if status == StatusCode::FORBIDDEN || status == StatusCode::TOO_MANY_REQUESTS {
+ warn!("{service} API rate limit or access restriction while {action}: HTTP {status}");
+ }
+}
+
+fn determine_contributor_repo_limits(
+ repo_clone_limit: Option,
+ user_count: usize,
+ service: &str,
+) -> (Option, Option) {
+ let Some(limit) = repo_clone_limit else {
+ return (None, None);
+ };
+ if user_count == 0 {
+ return (Some(0), Some(limit));
+ }
+ if user_count > limit {
+ let per_user_limit = std::cmp::max(1, limit / 100);
+ info!(
+ "Found {user_count} {service} contributors which exceeds repo-clone-limit {limit}. \
+Consider increasing repo-clone-limit; sampling {per_user_limit} repos per user until the limit is reached."
+ );
+ return (Some(per_user_limit), Some(limit));
+ }
+ let per_user_limit = std::cmp::max(1, limit / user_count);
+ (Some(per_user_limit), Some(limit))
+}
+
+fn build_contributor_progress_bar(
+ progress_enabled: bool,
+ length: u64,
+ message: &str,
+) -> ProgressBar {
+ if progress_enabled {
+ let style = ProgressStyle::with_template("{spinner} {msg} {pos}/{len} [{elapsed_precise}]")
+ .expect("progress bar style template should compile");
+ let pb = ProgressBar::new(length).with_style(style).with_message(message.to_string());
+ pb.enable_steady_tick(Duration::from_millis(500));
+ pb
+ } else {
+ ProgressBar::hidden()
+ }
+}
pub async fn enumerate_repo_urls(
repo_specifiers: &RepoSpecifiers,
github_url: url::Url,
diff --git a/src/gitlab.rs b/src/gitlab.rs
index f5cdbfb..df394fa 100644
--- a/src/gitlab.rs
+++ b/src/gitlab.rs
@@ -18,10 +18,11 @@ use gitlab::{
};
use globset::{Glob, GlobSet, GlobSetBuilder};
use indicatif::{ProgressBar, ProgressStyle};
+use reqwest::StatusCode;
use serde::Deserialize;
use serde_json::Value;
use tokio::task;
-use tracing::warn;
+use tracing::{info, warn};
use url::{form_urlencoded, Url};
use crate::{findings_store, git_url::GitUrl};
@@ -42,6 +43,25 @@ struct SimpleGroup {
id: u64,
}
+#[derive(Deserialize)]
+struct GitLabProjectId {
+ id: u64,
+}
+
+#[derive(Deserialize)]
+struct GitLabContributor {
+ name: String,
+ email: Option,
+}
+
+#[derive(Deserialize)]
+struct GitLabUser {
+ id: u64,
+ _username: String,
+ name: String,
+ email: Option,
+}
+
/// Repository filter types for GitLab
#[derive(Debug, Clone)]
pub enum RepoType {
@@ -206,6 +226,15 @@ fn create_gitlab_client(gitlab_url: &Url, ignore_certs: bool) -> Result
Ok(builder.build()?)
}
+fn normalize_api_base(api_url: &Url) -> Url {
+ let mut base = api_url.clone();
+ if !base.path().ends_with('/') {
+ let path = format!("{}/", base.path());
+ base.set_path(&path);
+ }
+ base
+}
+
pub async fn enumerate_repo_urls(
repo_specifiers: &RepoSpecifiers,
gitlab_url: Url,
@@ -222,6 +251,217 @@ pub async fn enumerate_repo_urls(
Ok(repo_urls)
}
+pub async fn enumerate_contributor_repo_urls(
+ repo_url: &GitUrl,
+ gitlab_url: &Url,
+ ignore_certs: bool,
+ exclude_repos: &[String],
+ repo_clone_limit: Option,
+ progress_enabled: bool,
+) -> Result> {
+ let (_, path) = parse_repo(repo_url).context("invalid GitLab repo URL")?;
+ let encoded = form_urlencoded::byte_serialize(path.as_bytes()).collect::();
+ let exclude_set = build_exclude_matcher(exclude_repos);
+ let client = reqwest::Client::builder().danger_accept_invalid_certs(ignore_certs).build()?;
+ let token = env::var("KF_GITLAB_TOKEN").ok().filter(|t| !t.is_empty());
+ let api_base = normalize_api_base(gitlab_url);
+
+ let project_url = api_base
+ .join(&format!("api/v4/projects/{encoded}"))
+ .context("Failed to build GitLab project URL")?;
+ let mut project_req = client.get(project_url);
+ if let Some(token) = token.as_ref() {
+ project_req = project_req.header("PRIVATE-TOKEN", token);
+ }
+ let project_resp = project_req.send().await?;
+ if !project_resp.status().is_success() {
+ warn_on_rate_limit("GitLab", project_resp.status(), "fetching project metadata");
+ return Ok(Vec::new());
+ }
+ let project: GitLabProjectId = project_resp.json().await?;
+ let project_id = project.id;
+
+ let mut contributors = Vec::new();
+ let mut page = 1;
+ loop {
+ let mut url = api_base
+ .join(&format!("api/v4/projects/{project_id}/repository/contributors"))
+ .context("Failed to build GitLab contributors URL")?;
+ url.query_pairs_mut().append_pair("per_page", "100").append_pair("page", &page.to_string());
+ let mut req = client.get(url);
+ if let Some(token) = token.as_ref() {
+ req = req.header("PRIVATE-TOKEN", token);
+ }
+ let resp = req.send().await?;
+ if !resp.status().is_success() {
+ warn_on_rate_limit("GitLab", resp.status(), "listing contributors");
+ break;
+ }
+ let page_contributors: Vec = resp.json().await?;
+ if page_contributors.is_empty() {
+ break;
+ }
+ contributors.extend(page_contributors);
+ page += 1;
+ }
+
+ let mut seen_users = HashSet::new();
+ let mut users = Vec::new();
+ for contributor in contributors {
+ let query = contributor.email.as_deref().unwrap_or(&contributor.name);
+ let mut url = api_base.join("api/v4/users").context("Failed to build GitLab users URL")?;
+ url.query_pairs_mut().append_pair("search", query);
+ let mut req = client.get(url);
+ if let Some(token) = token.as_ref() {
+ req = req.header("PRIVATE-TOKEN", token);
+ }
+ let resp = req.send().await?;
+ if !resp.status().is_success() {
+ warn_on_rate_limit("GitLab", resp.status(), "searching for contributor users");
+ continue;
+ }
+ let users_resp: Vec = resp.json().await?;
+ let matching = users_resp.into_iter().find(|user| {
+ contributor
+ .email
+ .as_ref()
+ .and_then(|email| user.email.as_ref().map(|u| (email, u)))
+ .map(|(email, user_email)| email.eq_ignore_ascii_case(user_email))
+ .unwrap_or_else(|| user.name.eq_ignore_ascii_case(&contributor.name))
+ });
+ let Some(user) = matching else {
+ continue;
+ };
+ if !seen_users.insert(user.id) {
+ continue;
+ }
+ users.push(user);
+ }
+
+ let (per_user_limit, total_limit) =
+ determine_contributor_repo_limits(repo_clone_limit, users.len(), "GitLab");
+ let progress = build_contributor_progress_bar(
+ progress_enabled,
+ users.len() as u64,
+ "Enumerating GitLab contributor repositories...",
+ );
+
+ let mut repo_urls = Vec::new();
+ let mut total_repo_count = 0usize;
+ for user in users {
+ if let Some(total_limit) = total_limit {
+ if total_repo_count >= total_limit {
+ break;
+ }
+ }
+ let mut user_repo_count = 0usize;
+ page = 1;
+ loop {
+ if let Some(per_user_limit) = per_user_limit {
+ if user_repo_count >= per_user_limit {
+ break;
+ }
+ }
+ if let Some(total_limit) = total_limit {
+ if total_repo_count >= total_limit {
+ break;
+ }
+ }
+ let mut url = api_base
+ .join(&format!("api/v4/users/{}/projects", user.id))
+ .context("Failed to build GitLab user projects URL")?;
+ url.query_pairs_mut()
+ .append_pair("per_page", "100")
+ .append_pair("page", &page.to_string())
+ .append_pair("order_by", "updated_at")
+ .append_pair("sort", "desc");
+ let mut req = client.get(url);
+ if let Some(token) = token.as_ref() {
+ req = req.header("PRIVATE-TOKEN", token);
+ }
+ let resp = req.send().await?;
+ if !resp.status().is_success() {
+ warn_on_rate_limit("GitLab", resp.status(), "listing user projects");
+ break;
+ }
+ let projects: Vec = resp.json().await?;
+ if projects.is_empty() {
+ break;
+ }
+ for proj in projects {
+ if let Some(per_user_limit) = per_user_limit {
+ if user_repo_count >= per_user_limit {
+ break;
+ }
+ }
+ if let Some(total_limit) = total_limit {
+ if total_repo_count >= total_limit {
+ break;
+ }
+ }
+ if should_exclude_repo(&proj.http_url_to_repo, &exclude_set) {
+ continue;
+ }
+ repo_urls.push(proj.http_url_to_repo);
+ user_repo_count += 1;
+ total_repo_count += 1;
+ }
+ page += 1;
+ }
+ progress.inc(1);
+ }
+
+ repo_urls.sort();
+ repo_urls.dedup();
+ progress.finish_and_clear();
+ Ok(repo_urls)
+}
+
+fn warn_on_rate_limit(service: &str, status: StatusCode, action: &str) {
+ if status == StatusCode::FORBIDDEN || status == StatusCode::TOO_MANY_REQUESTS {
+ warn!("{service} API rate limit or access restriction while {action}: HTTP {status}");
+ }
+}
+
+fn determine_contributor_repo_limits(
+ repo_clone_limit: Option,
+ user_count: usize,
+ service: &str,
+) -> (Option, Option) {
+ let Some(limit) = repo_clone_limit else {
+ return (None, None);
+ };
+ if user_count == 0 {
+ return (Some(0), Some(limit));
+ }
+ if user_count > limit {
+ let per_user_limit = std::cmp::max(1, limit / 100);
+ info!(
+ "Found {user_count} {service} contributors which exceeds repo-clone-limit {limit}. \
+Consider increasing repo-clone-limit; sampling {per_user_limit} repos per user until the limit is reached."
+ );
+ return (Some(per_user_limit), Some(limit));
+ }
+ let per_user_limit = std::cmp::max(1, limit / user_count);
+ (Some(per_user_limit), Some(limit))
+}
+
+fn build_contributor_progress_bar(
+ progress_enabled: bool,
+ length: u64,
+ message: &str,
+) -> ProgressBar {
+ if progress_enabled {
+ let style = ProgressStyle::with_template("{spinner} {msg} {pos}/{len} [{elapsed_precise}]")
+ .expect("progress bar style template should compile");
+ let pb = ProgressBar::new(length).with_style(style).with_message(message.to_string());
+ pb.enable_steady_tick(Duration::from_millis(500));
+ pb
+ } else {
+ ProgressBar::hidden()
+ }
+}
+
fn enumerate_repo_urls_blocking(
repo_specifiers: &RepoSpecifiers,
gitlab_url: Url,
diff --git a/src/main.rs b/src/main.rs
index 3026579..9384e7e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -51,6 +51,7 @@ use kingfisher::{
findings_store,
findings_store::FindingsStore,
gitea, github, huggingface,
+ reporter::{styles::Styles, DetailsReporter},
rule_loader::RuleLoader,
rules_database::RulesDatabase,
scanner::{load_and_record_rules, run_scan},
@@ -197,14 +198,25 @@ async fn async_main(args: CommandLineArgs) -> Result<()> {
Command::View(view_args) => view::run(view_args).await,
Command::AccessMap(identity_args) => access_map::run(identity_args).await,
command => {
- let temp_dir = TempDir::new().context("Failed to create temporary directory")?;
- let clone_dir = temp_dir.path().to_path_buf();
-
- let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir)));
let update_status = check_for_update_async(&global_args, None).await;
match command {
Command::Scan(scan_command) => match scan_command.into_operation()? {
ScanOperation::Scan(mut scan_args) => {
+ let temp_dir =
+ TempDir::new().context("Failed to create temporary directory")?;
+ let temp_dir_path = temp_dir.path().to_path_buf();
+ let clone_dir = if let Some(clone_dir) =
+ scan_args.input_specifier_args.git_clone_dir.as_ref()
+ {
+ std::fs::create_dir_all(clone_dir)?;
+ clone_dir.to_path_buf()
+ } else {
+ temp_dir_path.clone()
+ };
+ let keep_clones = scan_args.input_specifier_args.keep_clones
+ && scan_args.input_specifier_args.git_clone_dir.is_none();
+
+ let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir)));
info!(
"Launching with {} concurrent scan jobs. Use --num-jobs to override.",
&scan_args.num_jobs
@@ -214,7 +226,7 @@ async fn async_main(args: CommandLineArgs) -> Result<()> {
if (paths.is_empty() || is_dash) && !atty::is(atty::Stream::Stdin) {
let mut buf = Vec::new();
std::io::stdin().read_to_end(&mut buf)?;
- let stdin_file = temp_dir.path().join("stdin_input");
+ let stdin_file = temp_dir_path.join("stdin_input");
std::fs::write(&stdin_file, buf)?;
scan_args.input_specifier_args.path_inputs = vec![stdin_file.into()];
}
@@ -239,9 +251,29 @@ async fn async_main(args: CommandLineArgs) -> Result<()> {
}
let exit_code = determine_exit_code(&datastore);
- if let Err(e) = temp_dir.close() {
+ if scan_args.view_report {
+ let reporter = DetailsReporter {
+ datastore: Arc::clone(&datastore),
+ styles: Styles::new(global_args.use_color(std::io::stdout())),
+ only_valid: scan_args.only_valid,
+ };
+ let envelope = reporter.build_report_envelope(&scan_args)?;
+ let report_bytes = serde_json::to_vec_pretty(&envelope)?;
+ let view_args = view::ViewArgs {
+ report: None,
+ port: view::DEFAULT_PORT,
+ open_browser: true,
+ report_bytes: Some(report_bytes),
+ };
+ view::run(view_args).await?;
+ }
+
+ if keep_clones {
+ let _kept_path = temp_dir.keep(); // consumes TempDir; prevents auto-delete
+ } else if let Err(e) = temp_dir.close() {
eprintln!("Failed to close temporary directory: {}", e);
}
+
std::process::exit(exit_code);
}
ScanOperation::ListRepositories(list_command) => match list_command {
@@ -373,6 +405,10 @@ fn create_default_scan_args() -> cli::commands::scan::ScanArgs {
input_specifier_args: InputSpecifierArgs {
path_inputs: Vec::new(),
git_url: Vec::new(),
+ git_clone_dir: None,
+ keep_clones: false,
+ repo_clone_limit: None,
+ include_contributors: false,
github_user: Vec::new(),
github_organization: Vec::new(),
github_exclude: Vec::new(),
@@ -467,6 +503,7 @@ fn create_default_scan_args() -> cli::commands::scan::ScanArgs {
redact: false,
git_repo_timeout: 1800,
no_dedup: false,
+ view_report: false,
baseline_file: None,
manage_baseline: false,
skip_regex: Vec::new(),
@@ -477,6 +514,8 @@ fn create_default_scan_args() -> cli::commands::scan::ScanArgs {
no_base64: false,
no_inline_ignore: false,
no_ignore_if_contains: false,
+ validation_timeout: 10,
+ validation_retries: 1,
}
}
/// Run the rules check command
diff --git a/src/reporter.rs b/src/reporter.rs
index 0812202..3d14780 100644
--- a/src/reporter.rs
+++ b/src/reporter.rs
@@ -12,13 +12,13 @@ use serde::Serialize;
use url::Url;
use crate::{
- access_map::{AccessSummary, ResourceExposure},
+ access_map::{AccessSummary, AccessTokenDetails, ProviderMetadata, ResourceExposure},
blob::BlobMetadata,
bstring_escape::Escaped,
cli,
cli::global::GlobalArgs,
finding_data, findings_store,
- matcher::Match,
+ matcher::{compute_finding_fingerprint, Match},
origin::{Origin, OriginSet},
rules::rule::Confidence,
validation_body::{self, ValidationResponseBody},
@@ -227,6 +227,57 @@ impl DetailsReporter {
}
}
+ fn normalized_finding_fingerprint(m: &Match, origin: &OriginSet) -> u64 {
+ let finding_value = m
+ .groups
+ .captures
+ .get(1)
+ .or_else(|| m.groups.captures.get(0))
+ .map(|capture| capture.raw_value())
+ .unwrap_or("");
+ let offset_start = m.location.offset_span.start as u64;
+ let offset_end = m.location.offset_span.end as u64;
+ let has_file = origin.iter().any(|o| matches!(o, Origin::File(_)));
+ let has_git = origin.iter().any(|o| matches!(o, Origin::GitRepo(_)));
+ let origin_key = if has_file || has_git { "file_git" } else { "ext" };
+ compute_finding_fingerprint(finding_value, origin_key, offset_start, offset_end)
+ }
+
+ fn origin_set_contains_git(origin: &OriginSet) -> bool {
+ origin.iter().any(|o| matches!(o, Origin::GitRepo(_)))
+ }
+
+ fn merge_origins_for_dedup(mut existing: ReportMatch, incoming: ReportMatch) -> ReportMatch {
+ let existing_has_git = Self::origin_set_contains_git(&existing.origin);
+ let incoming_has_git = Self::origin_set_contains_git(&incoming.origin);
+ let prefer_git = existing_has_git || incoming_has_git;
+
+ if incoming_has_git && !existing_has_git {
+ existing = incoming.clone();
+ }
+
+ let mut origins = Vec::new();
+ let mut push_unique = |origin: &Origin| {
+ if !origins.iter().any(|existing| existing == origin) {
+ origins.push(origin.clone());
+ }
+ };
+
+ for origin in existing.origin.iter().chain(incoming.origin.iter()) {
+ push_unique(origin);
+ }
+
+ if prefer_git {
+ origins.retain(|origin| matches!(origin, Origin::GitRepo(_)));
+ }
+
+ if let Some(origin_set) = OriginSet::try_from_iter(origins) {
+ existing.origin = origin_set;
+ }
+
+ existing
+ }
+
/// If the given file path corresponds to a Confluence page downloaded to disk,
/// return the URL for that page.
fn confluence_page_url(&self, path: &std::path::Path) -> Option {
@@ -339,23 +390,12 @@ impl DetailsReporter {
let mut by_fp: HashMap<(u64, String), ReportMatch> = HashMap::new();
for rm in matches {
- let key = (rm.m.finding_fingerprint, rm.m.rule.id().to_string());
+ let key = (
+ Self::normalized_finding_fingerprint(&rm.m, &rm.origin),
+ rm.m.rule.id().to_string(),
+ );
if let Some(existing) = by_fp.get_mut(&key) {
- // merge origin sets (keep first origin, append the rest)
- for o in rm.origin.iter() {
- if !existing.origin.iter().any(|e| e == o) {
- existing.origin = OriginSet::new(
- existing.origin.first().clone(),
- existing
- .origin
- .iter()
- .skip(1)
- .cloned()
- .chain(std::iter::once(o.clone()))
- .collect(),
- );
- }
- }
+ *existing = Self::merge_origins_for_dedup(existing.clone(), rm);
continue;
}
by_fp.insert(key, rm);
@@ -617,6 +657,8 @@ impl DetailsReporter {
provider: result.cloud.clone(),
account: account.clone(),
groups,
+ token_details: result.token_details.clone(),
+ provider_metadata: result.provider_metadata.clone(),
});
}
@@ -774,6 +816,10 @@ pub struct AccessMapEntry {
#[serde(skip_serializing_if = "Option::is_none")]
pub account: Option,
pub groups: Vec,
+ #[serde(default)]
+ pub token_details: Option,
+ #[serde(default)]
+ pub provider_metadata: Option,
}
#[derive(Serialize, JsonSchema, Clone, Debug)]
@@ -854,6 +900,10 @@ mod tests {
input_specifier_args: InputSpecifierArgs {
path_inputs: Vec::new(),
git_url: Vec::new(),
+ git_clone_dir: None,
+ keep_clones: false,
+ repo_clone_limit: None,
+ include_contributors: false,
github_user: Vec::new(),
github_organization: Vec::new(),
github_exclude: Vec::new(),
@@ -934,6 +984,7 @@ mod tests {
min_entropy: None,
rule_stats: false,
no_dedup: false,
+ view_report: false,
redact: false,
no_base64: false,
git_repo_timeout: 1_800,
@@ -946,6 +997,8 @@ mod tests {
skip_aws_account_file: None,
no_inline_ignore: false,
no_ignore_if_contains: false,
+ validation_timeout: 10,
+ validation_retries: 1,
}
}
@@ -958,7 +1011,7 @@ mod tests {
let commit_metadata = Arc::new(CommitMetadata {
commit_id: ObjectId::from_hex(b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap(),
committer_name: "Alice".into(),
- committer_email: "alice@example.com".into(),
+ committer_email: "alice@exmple.com".into(),
committer_timestamp: Time::new(0, 0),
});
let blob_path = "path/in/history.txt".to_string();
diff --git a/src/reporter/json_format.rs b/src/reporter/json_format.rs
index 2bce267..be0590b 100644
--- a/src/reporter/json_format.rs
+++ b/src/reporter/json_format.rs
@@ -73,6 +73,7 @@ mod tests {
cli::commands::scan::ScanArgs {
num_jobs: 1,
no_dedup: false,
+ view_report: false,
rules: RuleSpecifierArgs {
rules_path: Vec::new(),
rule: vec!["all".into()],
@@ -82,6 +83,10 @@ mod tests {
// local path / git URL inputs
path_inputs: Vec::new(),
git_url: Vec::new(),
+ git_clone_dir: None,
+ keep_clones: false,
+ repo_clone_limit: None,
+ include_contributors: false,
// GitHub
github_user: Vec::new(),
@@ -190,6 +195,8 @@ mod tests {
no_base64: false,
no_inline_ignore: false,
no_ignore_if_contains: false,
+ validation_timeout: 10,
+ validation_retries: 1,
}
}
diff --git a/src/scanner/enumerate.rs b/src/scanner/enumerate.rs
index 96f4ae6..bb9b1e0 100644
--- a/src/scanner/enumerate.rs
+++ b/src/scanner/enumerate.rs
@@ -1117,7 +1117,7 @@ mod tests {
let temp = tempdir()?;
let repo_path = temp.path().join("repo");
let repo = Git2Repository::init(&repo_path)?;
- let signature = Signature::now("tester", "tester@example.com")?;
+ let signature = Signature::now("tester", "tester@exmple.com")?;
let tracked_file = repo_path.join("secret.txt");
fs::create_dir_all(tracked_file.parent().unwrap())?;
diff --git a/src/scanner/repos.rs b/src/scanner/repos.rs
index 9339fa6..d8f1f1f 100644
--- a/src/scanner/repos.rs
+++ b/src/scanner/repos.rs
@@ -34,6 +34,42 @@ use crate::{
pub type DatastoreMessage = (OriginSet, BlobMetadata, Vec<(Option, Match)>);
+fn repo_host_contains(repo_url: &GitUrl, needle: &str) -> bool {
+ Url::parse(repo_url.as_str())
+ .ok()
+ .and_then(|url| url.host_str().map(|host| host.to_lowercase()))
+ .map(|host| host.contains(needle))
+ .unwrap_or(false)
+}
+
+fn apply_repo_clone_limit(
+ repo_urls: &mut Vec,
+ limit: Option,
+ predicate: impl Fn(&GitUrl) -> bool,
+) {
+ let Some(limit) = limit else {
+ return;
+ };
+ let mut limited = Vec::new();
+ let mut remaining = Vec::new();
+ for url in repo_urls.drain(..) {
+ if predicate(&url) {
+ limited.push(url);
+ } else {
+ remaining.push(url);
+ }
+ }
+ limited.sort();
+ limited.dedup();
+ if limited.len() > limit {
+ limited.truncate(limit);
+ }
+ limited.extend(remaining);
+ limited.sort();
+ limited.dedup();
+ *repo_urls = limited;
+}
+
pub fn clone_or_update_git_repos_streaming(
args: &scan::ScanArgs,
global_args: &global::GlobalArgs,
@@ -173,6 +209,41 @@ pub async fn enumerate_github_repos(
exclude_repos: args.input_specifier_args.github_exclude.clone(),
};
let mut repo_urls = args.input_specifier_args.git_url.clone();
+ if args.input_specifier_args.include_contributors {
+ for repo_url in &args.input_specifier_args.git_url {
+ if !repo_host_contains(repo_url, "github") {
+ continue;
+ }
+ match github::enumerate_contributor_repo_urls(
+ repo_url,
+ &args.input_specifier_args.github_api_url,
+ global_args.ignore_certs,
+ &args.input_specifier_args.github_exclude,
+ args.input_specifier_args.repo_clone_limit,
+ global_args.use_progress(),
+ )
+ .await
+ {
+ Ok(contributor_urls) => {
+ for repo_string in contributor_urls {
+ match GitUrl::from_str(&repo_string) {
+ Ok(repo_url) => repo_urls.push(repo_url),
+ Err(e) => {
+ error!(
+ "Failed to parse contributor repo URL from {repo_string}: {e}"
+ );
+ }
+ }
+ }
+ }
+ Err(err) => {
+ error!(
+ "Failed to enumerate GitHub contributor repositories for {repo_url}: {err}"
+ );
+ }
+ }
+ }
+ }
if !repo_specifiers.is_empty() {
let mut progress = if global_args.use_progress() {
let style =
@@ -214,6 +285,9 @@ pub async fn enumerate_github_repos(
HumanCount(num_found)
));
}
+ apply_repo_clone_limit(&mut repo_urls, args.input_specifier_args.repo_clone_limit, |url| {
+ repo_host_contains(url, "github")
+ });
repo_urls.sort();
repo_urls.dedup();
Ok(repo_urls)
@@ -233,6 +307,41 @@ pub async fn enumerate_gitlab_repos(
};
let mut repo_urls = args.input_specifier_args.git_url.clone();
+ if args.input_specifier_args.include_contributors {
+ for repo_url in &args.input_specifier_args.git_url {
+ if !repo_host_contains(repo_url, "gitlab") {
+ continue;
+ }
+ match gitlab::enumerate_contributor_repo_urls(
+ repo_url,
+ &args.input_specifier_args.gitlab_api_url,
+ global_args.ignore_certs,
+ &args.input_specifier_args.gitlab_exclude,
+ args.input_specifier_args.repo_clone_limit,
+ global_args.use_progress(),
+ )
+ .await
+ {
+ Ok(contributor_urls) => {
+ for repo_string in contributor_urls {
+ match GitUrl::from_str(&repo_string) {
+ Ok(repo_url) => repo_urls.push(repo_url),
+ Err(e) => {
+ error!(
+ "Failed to parse contributor repo URL from {repo_string}: {e}"
+ );
+ }
+ }
+ }
+ }
+ Err(err) => {
+ error!(
+ "Failed to enumerate GitLab contributor repositories for {repo_url}: {err}"
+ );
+ }
+ }
+ }
+ }
if !repo_specifiers.is_empty() {
let progress = if global_args.use_progress() {
let style =
@@ -277,6 +386,9 @@ pub async fn enumerate_gitlab_repos(
HumanCount(num_found)
));
}
+ apply_repo_clone_limit(&mut repo_urls, args.input_specifier_args.repo_clone_limit, |url| {
+ repo_host_contains(url, "gitlab")
+ });
repo_urls.sort();
repo_urls.dedup();
Ok(repo_urls)
diff --git a/src/scanner/runner.rs b/src/scanner/runner.rs
index 371724c..1371931 100644
--- a/src/scanner/runner.rs
+++ b/src/scanner/runner.rs
@@ -359,6 +359,8 @@ pub async fn run_async_scan(
args.num_jobs,
None,
access_map_collector.clone(),
+ Duration::from_secs(args.validation_timeout),
+ args.validation_retries,
)
.await?;
}
@@ -442,6 +444,8 @@ pub async fn run_async_scan(
args.num_jobs,
Some(0..initial_match_count),
access_map_collector.clone(),
+ Duration::from_secs(args.validation_timeout),
+ args.validation_retries,
)
.await?;
}
@@ -523,6 +527,8 @@ pub async fn run_async_scan(
args.num_jobs,
Some(0..match_count),
access_map.clone(),
+ Duration::from_secs(args.validation_timeout),
+ args.validation_retries,
))?;
}
}
@@ -591,6 +597,8 @@ pub async fn run_async_scan(
args.num_jobs,
None,
access_map_collector.clone(),
+ Duration::from_secs(args.validation_timeout),
+ args.validation_retries,
)
.await?;
}
@@ -642,7 +650,7 @@ async fn finalize_access_map(
let requests = collector.into_requests();
if requests.is_empty() {
- debug!("access-map enabled but no validated AWS or GCP credentials were collected; skipping report output");
+ debug!("access-map enabled but no validated AWS, GCP, or Azure credentials were collected; skipping report output");
let mut ds = datastore.lock().unwrap();
ds.set_access_map_results(Vec::new());
return Ok(());
@@ -707,7 +715,9 @@ fn maybe_hint_access_map(datastore: &Arc>, args: &scan::Sca
ds.get_matches().iter().any(|entry| {
let rule = &entry.2.rule;
entry.2.validation_success
- && matches!(rule.syntax().validation, Some(Validation::AWS | Validation::GCP))
+ && (matches!(rule.syntax().validation, Some(Validation::AWS | Validation::GCP))
+ || rule.id().starts_with("kingfisher.github.")
+ || rule.id().starts_with("kingfisher.gitlab."))
})
};
diff --git a/src/scanner/validation.rs b/src/scanner/validation.rs
index 46a0f4b..194f016 100644
--- a/src/scanner/validation.rs
+++ b/src/scanner/validation.rs
@@ -51,6 +51,37 @@ impl AccessMapCollector {
});
}
+ pub fn record_azure(&self, credential_json: &str, containers: Option>) {
+ let key = xxhash_rust::xxh3::xxh3_64(credential_json.as_bytes());
+ self.inner.entry(key).or_insert_with(|| AccessMapRequest::Azure {
+ credential_json: credential_json.to_string(),
+ containers,
+ });
+ }
+
+ pub fn record_azure_devops(&self, token: &str, organization: &str) {
+ let key =
+ xxhash_rust::xxh3::xxh3_64(format!("azure_devops|{organization}|{token}").as_bytes());
+ self.inner.entry(key).or_insert_with(|| AccessMapRequest::AzureDevops {
+ token: token.to_string(),
+ organization: organization.to_string(),
+ });
+ }
+
+ pub fn record_github(&self, token: &str) {
+ let key = xxhash_rust::xxh3::xxh3_64(format!("github|{token}").as_bytes());
+ self.inner
+ .entry(key)
+ .or_insert_with(|| AccessMapRequest::Github { token: token.to_string() });
+ }
+
+ pub fn record_gitlab(&self, token: &str) {
+ let key = xxhash_rust::xxh3::xxh3_64(format!("gitlab|{token}").as_bytes());
+ self.inner
+ .entry(key)
+ .or_insert_with(|| AccessMapRequest::Gitlab { token: token.to_string() });
+ }
+
pub fn into_requests(self) -> Vec {
self.inner.iter().map(|entry| entry.value().clone()).collect()
}
@@ -65,6 +96,8 @@ pub async fn run_secret_validation(
num_jobs: usize,
range: Option>,
access_map: Option,
+ validation_timeout: Duration,
+ validation_retries: u32,
) -> Result<()> {
// ── 1. Concurrency & counters ───────────────────────────────────────────
let concurrency = if num_jobs > 0 { num_jobs } else { num_cpus::get() };
@@ -185,6 +218,8 @@ pub async fn run_secret_validation(
&fail,
&cache_glob,
access_map.as_ref(),
+ validation_timeout,
+ validation_retries,
)
.await;
@@ -258,6 +293,8 @@ pub async fn run_secret_validation(
let fail = fail_count.clone();
let cache_glob = cache.clone();
let access_map = access_map.clone();
+ let validation_timeout = validation_timeout;
+ let validation_retries = validation_retries;
async move {
let owned = matches_for_blob
@@ -292,7 +329,6 @@ pub async fn run_secret_validation(
let fail = fail.clone();
let cache_glob = cache_glob.clone();
let access_map = access_map.clone();
-
async move {
validate_single(
&mut rep,
@@ -306,6 +342,8 @@ pub async fn run_secret_validation(
&fail,
&cache_glob,
access_map.as_ref(),
+ validation_timeout,
+ validation_retries,
)
.await;
for d in &mut dups {
@@ -388,6 +426,8 @@ async fn validate_single(
fail_count: &AtomicUsize,
cache2: &Arc>,
access_map: Option<&AccessMapCollector>,
+ validation_timeout: Duration,
+ validation_retries: u32,
) {
// Build key
let dep_vars_str = dep_vars
@@ -438,8 +478,18 @@ async fn validate_single(
}
// If we reach here, we're the first task to validate this key
// Perform validation
- let outcome = timeout(Duration::from_secs(30), async {
- validate_single_match(om, parser, client, dep_vars, missing_deps, cache2).await
+ let outcome = timeout(validation_timeout, async {
+ validate_single_match(
+ om,
+ parser,
+ client,
+ dep_vars,
+ missing_deps,
+ cache2,
+ validation_timeout,
+ validation_retries,
+ )
+ .await
})
.await;
// Store result in cache
@@ -497,8 +547,11 @@ fn build_cache_key(
}
fn maybe_record_access_map(om: &OwnedBlobMatch, collector: Option<&AccessMapCollector>) {
+ let is_gitlab_rule = om.rule.id().starts_with("kingfisher.gitlab.");
+ let validation_ok =
+ om.validation_success || (is_gitlab_rule && om.validation_response_status.is_success());
let collector = match collector {
- Some(c) if om.validation_success => c,
+ Some(c) if validation_ok => c,
_ => return,
};
@@ -530,7 +583,67 @@ fn maybe_record_access_map(om: &OwnedBlobMatch, collector: Option<&AccessMapColl
}
}
}
- _ => {}
+ Some(Validation::AzureStorage) => {
+ let storage_key = captures
+ .iter()
+ .find(|(name, ..)| name == "TOKEN")
+ .map(|(_, value, ..)| value.clone())
+ .unwrap_or_default();
+ let storage_account =
+ utils::find_closest_variable(&captures, &storage_key, "TOKEN", "AZURENAME")
+ .unwrap_or_default();
+
+ let mut storage_account = storage_account;
+ if storage_account.is_empty() {
+ storage_account =
+ extract_azure_storage_account_from_body(&om.validation_response_body)
+ .unwrap_or_default();
+ }
+ let containers_hint =
+ extract_azure_storage_containers_from_body(&om.validation_response_body);
+
+ if !storage_account.is_empty() && !storage_key.is_empty() {
+ let creds_json = format!(
+ r#"{{"storage_account":"{}","storage_key":"{}"}}"#,
+ storage_account, storage_key
+ );
+ collector.record_azure(&creds_json, containers_hint);
+ }
+ }
+ _ => {
+ if om.rule.id().starts_with("kingfisher.github.") {
+ if let Some((_, value, ..)) = captures.iter().find(|(name, ..)| name == "TOKEN") {
+ if !value.is_empty() {
+ collector.record_github(value);
+ }
+ }
+ }
+ if om.rule.id().starts_with("kingfisher.azure.devops.") {
+ let token = captures
+ .iter()
+ .find(|(name, ..)| name == "TOKEN")
+ .map(|(_, value, ..)| value.clone())
+ .unwrap_or_default();
+ let mut organization =
+ utils::find_closest_variable(&captures, &token, "TOKEN", "AZURE_DEVOPS_ORG")
+ .unwrap_or_default();
+ if organization.is_empty() {
+ organization = extract_azure_devops_org_from_body(&om.validation_response_body)
+ .unwrap_or_default();
+ }
+
+ if !token.is_empty() && !organization.is_empty() {
+ collector.record_azure_devops(&token, &organization);
+ }
+ }
+ if is_gitlab_rule {
+ if let Some((_, value, ..)) = captures.iter().find(|(name, ..)| name == "TOKEN") {
+ if !value.is_empty() {
+ collector.record_gitlab(value);
+ }
+ }
+ }
+ }
}
}
@@ -545,3 +658,40 @@ fn extract_akid_from_body(body: &validation_body::ValidationResponseBody) -> Opt
let text = validation_body::clone_as_string(body);
AKID_RE.find(&text).map(|m| m.as_str().to_string())
}
+
+fn extract_azure_storage_account_from_body(
+ body: &validation_body::ValidationResponseBody,
+) -> Option {
+ static ACCOUNT_RE: once_cell::sync::Lazy = once_cell::sync::Lazy::new(|| {
+ regex::Regex::new(r"(?i)Account:\s*([a-z0-9]{3,24})").expect("valid regex")
+ });
+
+ let text = validation_body::clone_as_string(body);
+ ACCOUNT_RE.captures(&text).and_then(|caps| caps.get(1).map(|m| m.as_str().to_string()))
+}
+
+fn extract_azure_storage_containers_from_body(
+ body: &validation_body::ValidationResponseBody,
+) -> Option> {
+ static CONTAINERS_RE: once_cell::sync::Lazy = once_cell::sync::Lazy::new(|| {
+ regex::Regex::new(r"(?i)Containers:\s*(\\[[^\\]]*\\])").expect("valid regex")
+ });
+
+ let text = validation_body::clone_as_string(body);
+ let capture = CONTAINERS_RE
+ .captures(&text)
+ .and_then(|caps| caps.get(1).map(|m| m.as_str().to_string()))?;
+ serde_json::from_str::>(&capture).ok()
+}
+
+fn extract_azure_devops_org_from_body(
+ body: &validation_body::ValidationResponseBody,
+) -> Option {
+ static ORG_RE: once_cell::sync::Lazy = once_cell::sync::Lazy::new(|| {
+ regex::Regex::new(r#"(?i)https?://dev\.azure\.com/([a-z0-9][a-z0-9-]{0,61}[a-z0-9])"#)
+ .expect("valid regex")
+ });
+
+ let text = validation_body::clone_as_string(body);
+ ORG_RE.captures(&text).and_then(|caps| caps.get(1).map(|m| m.as_str().to_string()))
+}
diff --git a/src/validation.rs b/src/validation.rs
index 40a73b5..2076947 100644
--- a/src/validation.rs
+++ b/src/validation.rs
@@ -245,7 +245,7 @@ async fn render_template(
})
}
-/// Validate a single match with a timeout of 60 seconds.
+/// Validate a single match with a configurable timeout.
pub async fn validate_single_match(
m: &mut OwnedBlobMatch,
parser: &liquid::Parser,
@@ -253,8 +253,10 @@ pub async fn validate_single_match(
dependent_variables: &FxHashMap>,
missing_dependencies: &FxHashMap>,
cache: &Cache,
+ validation_timeout: Duration,
+ validation_retries: u32,
) {
- let timeout_result = time::timeout(Duration::from_secs(60), async {
+ let timeout_result = time::timeout(validation_timeout, async {
timed_validate_single_match(
m,
parser,
@@ -262,6 +264,8 @@ pub async fn validate_single_match(
dependent_variables,
missing_dependencies,
cache,
+ validation_timeout,
+ validation_retries,
)
.await
})
@@ -269,8 +273,10 @@ pub async fn validate_single_match(
if timeout_result.is_err() {
m.validation_success = false;
- m.validation_response_body =
- validation_body::from_string("Validation timed out after 60 seconds");
+ m.validation_response_body = validation_body::from_string(format!(
+ "Validation timed out after {} seconds",
+ validation_timeout.as_secs()
+ ));
m.validation_response_status = StatusCode::REQUEST_TIMEOUT;
}
}
@@ -285,6 +291,8 @@ async fn timed_validate_single_match<'a>(
dependent_variables: &FxHashMap>,
missing_dependencies: &FxHashMap>,
cache: &Cache,
+ validation_timeout: Duration,
+ validation_retries: u32,
) {
// ──────────────────────────────────────────────────────────
// 1. process-wide fingerprint de-dup
@@ -383,6 +391,9 @@ async fn timed_validate_single_match<'a>(
match &rule_syntax.validation {
// ---------------------------------------------------- HTTP validator
Some(Validation::Http(http_validation)) => {
+ let request_timeout = validation_timeout;
+ let multipart_timeout = validation_timeout;
+ let max_retries: u32 = validation_retries;
// render URL
let url = match render_and_parse_url(
parser,
@@ -409,6 +420,7 @@ async fn timed_validate_single_match<'a>(
&url,
&http_validation.request.headers,
&http_validation.request.body,
+ request_timeout,
parser,
&globals,
) {
@@ -462,7 +474,7 @@ async fn timed_validate_single_match<'a>(
let exec_single = |builder: reqwest::RequestBuilder| async {
httpvalidation::retry_request(
builder,
- 1,
+ max_retries,
Duration::from_millis(500),
Duration::from_secs(2),
)
@@ -477,7 +489,7 @@ async fn timed_validate_single_match<'a>(
.unwrap_or(reqwest::Method::GET);
let mut fresh_builder =
- client.request(method, url.clone()).timeout(Duration::from_secs(5));
+ client.request(method, url.clone()).timeout(multipart_timeout);
if let Ok(mut headers) = httpvalidation::process_headers(
&http_validation.request.headers,
@@ -546,7 +558,7 @@ async fn timed_validate_single_match<'a>(
httpvalidation::retry_multipart_request(
build_request,
- 1,
+ max_retries as usize,
Duration::from_millis(500),
Duration::from_secs(2),
)
diff --git a/src/validation/coinbase.rs b/src/validation/coinbase.rs
index 33dc6e8..c3f2873 100644
--- a/src/validation/coinbase.rs
+++ b/src/validation/coinbase.rs
@@ -55,6 +55,7 @@ pub async fn validate_cdp_api_key(
&url,
&headers,
&None,
+ Duration::from_secs(10),
parser,
&liquid::Object::new(),
)
diff --git a/src/validation/httpvalidation.rs b/src/validation/httpvalidation.rs
index 2a23d65..6b31bf8 100644
--- a/src/validation/httpvalidation.rs
+++ b/src/validation/httpvalidation.rs
@@ -65,6 +65,7 @@ pub fn build_request_builder(
url: &Url,
headers: &BTreeMap,
body: &Option,
+ timeout: Duration,
parser: &liquid::Parser,
globals: &liquid::Object,
) -> Result {
@@ -72,7 +73,7 @@ pub fn build_request_builder(
debug!("{}", err_msg);
err_msg
})?;
- let mut request_builder = client.request(method, url.clone()).timeout(Duration::from_secs(10));
+ let mut request_builder = client.request(method, url.clone()).timeout(timeout);
let custom_headers = process_headers(headers, parser, globals, url)
.map_err(|e| format!("Error processing headers: {}", e))?;
@@ -199,6 +200,9 @@ where
return result;
}
retries += 1;
+ if retries > max_retries {
+ break;
+ }
let backoff = backoff_min.saturating_mul(2u32.pow(retries as u32)).min(backoff_max);
sleep(backoff).await;
}
@@ -445,9 +449,17 @@ mod tests {
("Accept".to_string(), "application/custom".to_string()),
]);
let url = Url::from_str("https://example.com").unwrap();
- let result =
- build_request_builder(&client, "GET", &url, &headers, &None, &parser, &globals)
- .expect("building request");
+ let result = build_request_builder(
+ &client,
+ "GET",
+ &url,
+ &headers,
+ &None,
+ Duration::from_secs(10),
+ &parser,
+ &globals,
+ )
+ .expect("building request");
let req = result.build().expect("finalizing request");
assert_eq!(
req.headers().get(header::ACCEPT).and_then(|v| v.to_str().ok()),
diff --git a/src/validation/mysql.rs b/src/validation/mysql.rs
index 4648844..d74db6b 100644
--- a/src/validation/mysql.rs
+++ b/src/validation/mysql.rs
@@ -133,22 +133,22 @@ mod tests {
#[test]
fn parse_mysql_url_accepts_valid_urls() {
- let url = "mysql://user:secret@example.com:3306/app";
+ let url = "mysql://user:secret@exmple.com:3306/app";
let opts = parse_mysql_url(url).expect("expected valid MySQL URL");
assert_eq!(opts.user(), Some("user"));
assert_eq!(opts.pass(), Some("secret"));
- assert_eq!(opts.ip_or_hostname(), "example.com");
+ assert_eq!(opts.ip_or_hostname(), "exmple.com");
}
#[test]
fn parse_mysql_url_rejects_invalid_urls() {
for candidate in [
- "", // empty
- "mysql://user@example.com/app", // missing password
- "mysql://:secret@example.com/app", // missing username
- "mysql://user:secret@:3306/app", // missing host
- "postgres://user:secret@example.com", // wrong scheme
- "mysql://user:secret@example.com:70000/app", // invalid port
+ "", // empty
+ "mysql://user@exmple.com/app", // missing password
+ "mysql://:secret@exmple.com/app", // missing username
+ "mysql://user:secret@:3306/app", // missing host
+ "postgres://user:secret@exmple.com", // wrong scheme
+ "mysql://user:secret@exmple.com:70000/app", // invalid port
] {
assert!(
parse_mysql_url(candidate).is_err(),
@@ -160,7 +160,7 @@ mod tests {
#[test]
fn parse_mysql_url_allows_trimming_whitespace() {
let opts =
- parse_mysql_url(" mysql://user:secret@example.com:3306/app ").expect("trimmed URL");
+ parse_mysql_url(" mysql://user:secret@exmple.com:3306/app ").expect("trimmed URL");
assert_eq!(opts.user(), Some("user"));
assert_eq!(opts.pass(), Some("secret"));
}
diff --git a/src/validation/postgres.rs b/src/validation/postgres.rs
index ba4a013..198d067 100644
--- a/src/validation/postgres.rs
+++ b/src/validation/postgres.rs
@@ -242,13 +242,13 @@ mod tests {
#[test]
fn parse_accepts_postgis_scheme() {
- let url = "postgis://postgres:secret@example.com:5432";
+ let url = "postgis://postgres:secret@exmple.com:5432";
assert!(parse_postgres_url(url).is_ok(), "postgis scheme should be accepted");
}
#[test]
fn parse_rejects_invalid_port() {
- let url = "postgres://postgres:secret@example.com:70000";
+ let url = "postgres://postgres:secret@exmple.com:70000";
assert!(parse_postgres_url(url).is_err(), "invalid port should be rejected");
}
}
diff --git a/tests/cli_git_clone_flags.rs b/tests/cli_git_clone_flags.rs
new file mode 100644
index 0000000..fc7d40a
--- /dev/null
+++ b/tests/cli_git_clone_flags.rs
@@ -0,0 +1,63 @@
+use clap::Parser;
+use tempfile::tempdir;
+
+use kingfisher::cli::{
+ commands::scan::ScanOperation,
+ global::{Command, CommandLineArgs},
+};
+
+#[test]
+fn parse_git_clone_dir_and_keep_clones() -> anyhow::Result<()> {
+ let dir = tempdir()?;
+ let args = CommandLineArgs::try_parse_from([
+ "kingfisher",
+ "scan",
+ "--git-url",
+ "https://github.com/octocat/Hello-World.git",
+ "--git-clone-dir",
+ dir.path().to_str().unwrap(),
+ "--keep-clones",
+ "--no-update-check",
+ ])?;
+
+ let command = match args.command {
+ Command::Scan(scan_args) => scan_args,
+ other => panic!("unexpected command parsed: {:?}", other),
+ };
+
+ let scan_args = match command.into_operation()? {
+ ScanOperation::Scan(scan_args) => scan_args,
+ op => panic!("expected scan operation, got {:?}", op),
+ };
+
+ assert_eq!(scan_args.input_specifier_args.git_clone_dir.as_deref(), Some(dir.path()));
+ assert!(scan_args.input_specifier_args.keep_clones);
+
+ Ok(())
+}
+
+#[test]
+fn keep_clones_defaults_to_false() -> anyhow::Result<()> {
+ let args = CommandLineArgs::try_parse_from([
+ "kingfisher",
+ "scan",
+ "--git-url",
+ "https://github.com/octocat/Hello-World.git",
+ "--no-update-check",
+ ])?;
+
+ let command = match args.command {
+ Command::Scan(scan_args) => scan_args,
+ other => panic!("unexpected command parsed: {:?}", other),
+ };
+
+ let scan_args = match command.into_operation()? {
+ ScanOperation::Scan(scan_args) => scan_args,
+ op => panic!("expected scan operation, got {:?}", op),
+ };
+
+ assert!(scan_args.input_specifier_args.git_clone_dir.is_none());
+ assert!(!scan_args.input_specifier_args.keep_clones);
+
+ Ok(())
+}
diff --git a/tests/fingerprint_dedup.rs b/tests/fingerprint_dedup.rs
index 3186a48..fe29a39 100644
--- a/tests/fingerprint_dedup.rs
+++ b/tests/fingerprint_dedup.rs
@@ -77,7 +77,7 @@ fn dummy_commit(commit_id: &str) -> CommitMetadata {
CommitMetadata {
commit_id: oid,
committer_name: "tester".into(),
- committer_email: "tester@example.com".into(),
+ committer_email: "tester@exmple.com".into(),
committer_timestamp: ts,
}
}
diff --git a/tests/int_allowlist.rs b/tests/int_allowlist.rs
index 3e8bb21..6c9932f 100644
--- a/tests/int_allowlist.rs
+++ b/tests/int_allowlist.rs
@@ -60,6 +60,10 @@ fn run_skiplist(skip_regex: Vec, skip_skipword: Vec) -> Result, skip_skipword: Vec) -> Result, skip_skipword: Vec) -> Result Result<()> {
input_specifier_args: InputSpecifierArgs {
path_inputs: Vec::new(),
git_url: vec![git_url],
+ git_clone_dir: None,
+ keep_clones: false,
+ repo_clone_limit: None,
+ include_contributors: false,
github_user: Vec::new(),
github_organization: Vec::new(),
github_exclude: Vec::new(),
@@ -142,6 +146,7 @@ fn test_bitbucket_remote_scan() -> Result<()> {
git_repo_timeout: 1800,
output_args: OutputArgs { output: None, format: ReportOutputFormat::Pretty },
no_dedup: true,
+ view_report: false,
baseline_file: None,
manage_baseline: false,
skip_regex: Vec::new(),
@@ -152,6 +157,8 @@ fn test_bitbucket_remote_scan() -> Result<()> {
extra_ignore_comments: Vec::new(),
no_inline_ignore: false,
no_ignore_if_contains: false,
+ validation_retries: 1,
+ validation_timeout: 10,
};
let global_args = GlobalArgs {
diff --git a/tests/int_dedup.rs b/tests/int_dedup.rs
index 747c5f3..44b5d10 100644
--- a/tests/int_dedup.rs
+++ b/tests/int_dedup.rs
@@ -71,6 +71,10 @@ rules:
input_specifier_args: InputSpecifierArgs {
path_inputs: vec![inputs_dir.join("a.txt"), inputs_dir.join("b.txt")],
git_url: Vec::new(),
+ git_clone_dir: None,
+ keep_clones: false,
+ repo_clone_limit: None,
+ include_contributors: false,
github_user: Vec::new(),
github_organization: Vec::new(),
github_exclude: Vec::new(),
@@ -162,6 +166,7 @@ rules:
git_repo_timeout: 1800, // 30 minutes
output_args: OutputArgs { output: None, format: ReportOutputFormat::Pretty },
no_dedup,
+ view_report: false,
baseline_file: None,
manage_baseline: false,
skip_regex: Vec::new(),
@@ -172,6 +177,8 @@ rules:
extra_ignore_comments: Vec::new(),
no_inline_ignore: false,
no_ignore_if_contains: false,
+ validation_retries: 1,
+ validation_timeout: 10,
};
let global_args = GlobalArgs {
diff --git a/tests/int_github.rs b/tests/int_github.rs
index d5243cd..ea7b50a 100644
--- a/tests/int_github.rs
+++ b/tests/int_github.rs
@@ -58,6 +58,10 @@ fn test_github_remote_scan() -> Result<()> {
input_specifier_args: InputSpecifierArgs {
path_inputs: Vec::new(),
git_url: vec![git_url],
+ git_clone_dir: None,
+ keep_clones: false,
+ repo_clone_limit: None,
+ include_contributors: false,
github_user: Vec::new(),
github_organization: Vec::new(),
github_exclude: Vec::new(),
@@ -149,6 +153,7 @@ fn test_github_remote_scan() -> Result<()> {
git_repo_timeout: 1800, // 30 minutes
output_args: OutputArgs { output: None, format: ReportOutputFormat::Pretty },
no_dedup: true,
+ view_report: false,
baseline_file: None,
manage_baseline: false,
skip_regex: Vec::new(),
@@ -159,6 +164,8 @@ fn test_github_remote_scan() -> Result<()> {
extra_ignore_comments: Vec::new(),
no_inline_ignore: false,
no_ignore_if_contains: false,
+ validation_retries: 1,
+ validation_timeout: 10,
};
// Create global arguments
let global_args = GlobalArgs {
diff --git a/tests/int_gitlab.rs b/tests/int_gitlab.rs
index 745b974..484f950 100644
--- a/tests/int_gitlab.rs
+++ b/tests/int_gitlab.rs
@@ -58,6 +58,10 @@ fn test_gitlab_remote_scan() -> Result<()> {
input_specifier_args: InputSpecifierArgs {
path_inputs: Vec::new(),
git_url: vec![git_url],
+ git_clone_dir: None,
+ keep_clones: false,
+ repo_clone_limit: None,
+ include_contributors: false,
github_user: Vec::new(),
github_organization: Vec::new(),
github_exclude: Vec::new(),
@@ -148,6 +152,7 @@ fn test_gitlab_remote_scan() -> Result<()> {
git_repo_timeout: 1800, // 30 minutes
output_args: OutputArgs { output: None, format: ReportOutputFormat::Pretty },
no_dedup: true,
+ view_report: false,
baseline_file: None,
manage_baseline: false,
skip_regex: Vec::new(),
@@ -157,6 +162,8 @@ fn test_gitlab_remote_scan() -> Result<()> {
no_base64: false,
no_inline_ignore: false,
no_ignore_if_contains: false,
+ validation_retries: 1,
+ validation_timeout: 10,
};
let global_args = GlobalArgs {
@@ -216,6 +223,10 @@ fn test_gitlab_remote_scan_no_history() -> Result<()> {
input_specifier_args: InputSpecifierArgs {
path_inputs: Vec::new(),
git_url: vec![git_url],
+ git_clone_dir: None,
+ keep_clones: false,
+ repo_clone_limit: None,
+ include_contributors: false,
github_user: Vec::new(),
github_organization: Vec::new(),
github_exclude: Vec::new(),
@@ -313,6 +324,9 @@ fn test_gitlab_remote_scan_no_history() -> Result<()> {
extra_ignore_comments: Vec::new(),
no_inline_ignore: false,
no_ignore_if_contains: false,
+ view_report: false,
+ validation_retries: 1,
+ validation_timeout: 10,
};
let global_args = GlobalArgs {
diff --git a/tests/int_redact.rs b/tests/int_redact.rs
index 226e890..8ecc85d 100644
--- a/tests/int_redact.rs
+++ b/tests/int_redact.rs
@@ -43,6 +43,10 @@ async fn test_redact_hashes_finding_values() -> Result<()> {
input_specifier_args: InputSpecifierArgs {
path_inputs: vec![PathBuf::from("testdata/generic_secrets.py")],
git_url: Vec::new(),
+ git_clone_dir: None,
+ keep_clones: false,
+ repo_clone_limit: None,
+ include_contributors: false,
github_user: Vec::new(),
github_organization: Vec::new(),
github_exclude: Vec::new(),
@@ -125,6 +129,7 @@ async fn test_redact_hashes_finding_values() -> Result<()> {
git_repo_timeout: 1800,
output_args: OutputArgs { output: None, format: ReportOutputFormat::Pretty },
no_dedup: true,
+ view_report: false,
baseline_file: None,
manage_baseline: false,
skip_regex: Vec::new(),
@@ -135,6 +140,8 @@ async fn test_redact_hashes_finding_values() -> Result<()> {
extra_ignore_comments: Vec::new(),
no_inline_ignore: false,
no_ignore_if_contains: false,
+ validation_retries: 1,
+ validation_timeout: 10,
};
let global_args = GlobalArgs {
diff --git a/tests/int_slack.rs b/tests/int_slack.rs
index e630e3c..b8aeb5a 100644
--- a/tests/int_slack.rs
+++ b/tests/int_slack.rs
@@ -1,7 +1,4 @@
-use std::{
- env,
- sync::{Arc, Mutex},
-};
+use std::sync::{Arc, Mutex};
use anyhow::Result;
use kingfisher::{
@@ -49,6 +46,10 @@ impl TestContext {
input_specifier_args: InputSpecifierArgs {
path_inputs: Vec::new(),
git_url: Vec::new(),
+ git_clone_dir: None,
+ keep_clones: false,
+ repo_clone_limit: None,
+ include_contributors: false,
github_user: Vec::new(),
github_organization: Vec::new(),
github_exclude: Vec::new(),
@@ -134,6 +135,7 @@ impl TestContext {
git_repo_timeout: 1800,
output_args: OutputArgs { output: None, format: ReportOutputFormat::Pretty },
no_dedup: true,
+ view_report: false,
baseline_file: None,
manage_baseline: false,
skip_regex: Vec::new(),
@@ -143,6 +145,8 @@ impl TestContext {
no_base64: false,
no_inline_ignore: false,
no_ignore_if_contains: false,
+ validation_retries: 1,
+ validation_timeout: 10,
};
let loaded = RuleLoader::from_rule_specifiers(&scan_args.rules).load(&scan_args)?;
@@ -191,6 +195,10 @@ async fn test_scan_slack_messages() -> Result<()> {
input_specifier_args: InputSpecifierArgs {
path_inputs: Vec::new(),
git_url: Vec::new(),
+ git_clone_dir: None,
+ keep_clones: false,
+ repo_clone_limit: None,
+ include_contributors: false,
github_user: Vec::new(),
github_organization: Vec::new(),
github_exclude: Vec::new(),
@@ -286,6 +294,9 @@ async fn test_scan_slack_messages() -> Result<()> {
extra_ignore_comments: Vec::new(),
no_inline_ignore: false,
no_ignore_if_contains: false,
+ view_report: false,
+ validation_retries: 1,
+ validation_timeout: 10,
};
let global_args = GlobalArgs {
diff --git a/tests/int_uri_parsing.rs b/tests/int_uri_parsing.rs
index a8e7c8c..927640e 100644
--- a/tests/int_uri_parsing.rs
+++ b/tests/int_uri_parsing.rs
@@ -7,8 +7,8 @@ use tempfile::tempdir;
fn filters_invalid_mongodb_uri_even_without_validation() -> anyhow::Result<()> {
let dir = tempdir()?;
let file_path = dir.path().join("mongo.txt");
- let valid = "mongodb://usr:pass@example.com:27017/db";
- let invalid = "mongodb://usr:pass@example.com:abc/db";
+ let valid = "mongodb://usr:pass@exmple.com:27017/db";
+ let invalid = "mongodb://usr:pass@exmple.com:abc/db";
fs::write(&file_path, format!("{valid}\n{invalid}\n"))?;
Command::new(assert_cmd::cargo::cargo_bin!("kingfisher"))
@@ -35,8 +35,8 @@ fn filters_invalid_mongodb_uri_even_without_validation() -> anyhow::Result<()> {
fn filters_invalid_postgres_uri_even_without_validation() -> anyhow::Result<()> {
let dir = tempdir()?;
let file_path = dir.path().join("postgres.txt");
- let valid = "postgres://postgres:secret@example.com:5432";
- let invalid = "postgres://postgres:secret@example.com:70000";
+ let valid = "postgres://postgres:secret@exmple.com:5432";
+ let invalid = "postgres://postgres:secret@exmple.com:70000";
fs::write(&file_path, format!("{valid}\n{invalid}\n"))?;
Command::new(assert_cmd::cargo::cargo_bin!("kingfisher"))
@@ -63,8 +63,8 @@ fn filters_invalid_postgres_uri_even_without_validation() -> anyhow::Result<()>
fn filters_invalid_mysql_uri_even_without_validation() -> anyhow::Result<()> {
let dir = tempdir()?;
let file_path = dir.path().join("mysql.txt");
- let valid = "mysql://user:secret@example.com:3306/app";
- let invalid = "mysql://user:secret@example.com:70000/app";
+ let valid = "mysql://user:secret@exmple.com:3306/app";
+ let invalid = "mysql://user:secret@exmple.com:70000/app";
fs::write(&file_path, format!("{valid}\n{invalid}\n"))?;
Command::new(assert_cmd::cargo::cargo_bin!("kingfisher"))
diff --git a/tests/int_validation_cache.rs b/tests/int_validation_cache.rs
index 1be7890..69a9027 100644
--- a/tests/int_validation_cache.rs
+++ b/tests/int_validation_cache.rs
@@ -113,6 +113,10 @@ async fn test_validation_cache_and_depvars() -> Result<()> {
input_specifier_args: InputSpecifierArgs {
path_inputs: vec![secret_file.clone()],
git_url: Vec::new(),
+ git_clone_dir: None,
+ keep_clones: false,
+ repo_clone_limit: None,
+ include_contributors: false,
github_user: Vec::new(),
github_organization: Vec::new(),
github_exclude: Vec::new(),
@@ -205,6 +209,7 @@ async fn test_validation_cache_and_depvars() -> Result<()> {
git_repo_timeout: 1800, // 30 minutes
output_args: OutputArgs { output: None, format: ReportOutputFormat::Pretty },
no_dedup: true, // keep duplicates so the cache is stressed
+ view_report: false,
baseline_file: None,
manage_baseline: false,
skip_regex: Vec::new(),
@@ -215,6 +220,8 @@ async fn test_validation_cache_and_depvars() -> Result<()> {
extra_ignore_comments: Vec::new(),
no_inline_ignore: false,
no_ignore_if_contains: false,
+ validation_retries: 1,
+ validation_timeout: 10,
};
/* --------------------------------------------------------- *
diff --git a/tests/int_vulnerable_files.rs b/tests/int_vulnerable_files.rs
index 3669682..3aaa35e 100644
--- a/tests/int_vulnerable_files.rs
+++ b/tests/int_vulnerable_files.rs
@@ -57,6 +57,10 @@ impl TestContext {
input_specifier_args: InputSpecifierArgs {
path_inputs: Vec::new(),
git_url: Vec::new(),
+ git_clone_dir: None,
+ keep_clones: false,
+ repo_clone_limit: None,
+ include_contributors: false,
github_user: Vec::new(),
github_organization: Vec::new(),
github_exclude: Vec::new(),
@@ -148,6 +152,7 @@ impl TestContext {
git_repo_timeout: 1800, // 30 minutes
output_args: OutputArgs { output: None, format: ReportOutputFormat::Pretty },
no_dedup: true,
+ view_report: false,
baseline_file: None,
manage_baseline: false,
skip_regex: Vec::new(),
@@ -158,6 +163,8 @@ impl TestContext {
extra_ignore_comments: Vec::new(),
no_inline_ignore: false,
no_ignore_if_contains: false,
+ validation_retries: 1,
+ validation_timeout: 10,
};
let loaded = RuleLoader::from_rule_specifiers(&scan_args.rules)
@@ -186,6 +193,10 @@ impl TestContext {
input_specifier_args: InputSpecifierArgs {
path_inputs: vec![file_path.to_path_buf()],
git_url: Vec::new(),
+ git_clone_dir: None,
+ keep_clones: false,
+ repo_clone_limit: None,
+ include_contributors: false,
github_user: Vec::new(),
github_organization: Vec::new(),
github_exclude: Vec::new(),
@@ -279,6 +290,7 @@ impl TestContext {
git_repo_timeout: 1800, // 30 minutes
output_args: OutputArgs { output: None, format: ReportOutputFormat::Pretty },
no_dedup: true,
+ view_report: false,
baseline_file: None,
manage_baseline: false,
skip_regex: Vec::new(),
@@ -288,6 +300,8 @@ impl TestContext {
no_base64: false,
no_inline_ignore: false,
no_ignore_if_contains: false,
+ validation_retries: 1,
+ validation_timeout: 10,
};
let global_args = GlobalArgs {
diff --git a/tests/smoke_branch.rs b/tests/smoke_branch.rs
index 27f4d26..4634bfa 100644
--- a/tests/smoke_branch.rs
+++ b/tests/smoke_branch.rs
@@ -35,7 +35,7 @@ fn scan_by_commit_and_branch_diff() -> anyhow::Result<()> {
let dir = tempdir()?;
let repo_dir = dir.path().join("repo");
let repo = Repository::init(&repo_dir)?;
- let signature = Signature::now("tester", "tester@example.com")?;
+ let signature = Signature::now("tester", "tester@exmple.com")?;
// Commit an initial config file packed with known test secrets. We'll scan
// this commit directly via `--branch ` in the first assertion.
@@ -147,7 +147,7 @@ fn setup_linear_repo_with_secrets() -> Result<(TempDir, std::path::PathBuf, Vec<
let dir = tempdir()?;
let repo_dir = dir.path().join("repo");
let repo = Repository::init(&repo_dir)?;
- let sig = Signature::now("tester", "tester@example.com")?;
+ let sig = Signature::now("tester", "tester@exmple.com")?;
let secrets_path = repo_dir.join("secrets.txt");
diff --git a/tests/smoke_git.rs b/tests/smoke_git.rs
index 1336803..95c7388 100644
--- a/tests/smoke_git.rs
+++ b/tests/smoke_git.rs
@@ -11,7 +11,7 @@ fn smoke_scan_git_history() -> anyhow::Result<()> {
let dir = tempdir()?;
let repo_dir = dir.path().join("repo");
let repo = Repository::init(&repo_dir)?;
- let sig = Signature::now("tester", "tester@example.com")?;
+ let sig = Signature::now("tester", "tester@exmple.com")?;
// commit v1
let file_path = repo_dir.join("config.yml");