Merge pull request #184 from mongodb/development

v1.75.0
This commit is contained in:
Mick Grove 2026-01-16 15:30:34 -08:00 committed by GitHub
commit 1be10ee8c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 918 additions and 223 deletions

View file

@ -1,6 +1,15 @@
# Changelog
All notable changes to this project will be documented in this file.
## [v1.75.0]
- Enhanced Access Map View: added fingerprint display, enabled searching by fingerprint, and implemented bidirectional navigation between Findings and Access Map nodes.
- Added Slack Access Map support with granular permissions in the tree view.
- Improved HTML report
- Improved several rules
- Added new rules for Apollo, Clay, CodeRabbit, Customer.io, Instantly, Vast.ai
- Skipped per-repository report writes when an output file is specified and emit a single aggregated report after multi-repository scans to preserve full output content in files.
## [v1.74.0]
- Added new rules: cursor, definednetworking, filezilla, harness, intra42, klingai, lark, mergify, naver, plaid, resend, retellai

View file

@ -10,7 +10,7 @@ publish = false
[package]
name = "kingfisher"
version = "1.74.0"
version = "1.75.0"
description = "MongoDB's blazingly fast and accurate secret scanning and validation tool"
edition.workspace = true
rust-version.workspace = true

View file

@ -1,4 +1,4 @@
# Kingfisher
# Kingfisher
<p align="center">
<img src="docs/kingfisher_logo.png" alt="Kingfisher Logo" width="126" height="173" style="vertical-align: right;" />
@ -13,8 +13,6 @@ It combines Intels SIMD-accelerated regex engine (Hyperscan) with language-aw
Designed for offensive security engineers and blue-teamers alike, Kingfisher helps you pivot across repo ecosystems, validate exposure paths, and hunt for developer-owned leaks that spill beyond the primary codebase.
For a look at how Kingfisher has grown from its early foundations into today's full-featured scanner, see [Lineage and Evolution](#lineage-and-evolution).
</p>
## Key Features

52
data/rules/apollo.yml Normal file
View file

@ -0,0 +1,52 @@
rules:
- name: Apollo API Key
id: kingfisher.apollo.1
pattern: |
(?xi)
\b
apollo
(?:.|[\n\r]){0,16}?
(?:SECRET|PRIVATE|ACCESS|KEY|TOKEN)
(?:.|[\n\r]){0,32}?
\b
(
[A-Z0-9_-]{22}
)
\b
pattern_requirements:
min_digits: 2
min_uppercase: 1
min_lowercase: 1
min_entropy: 3.0
confidence: medium
examples:
- 'APOLLO_API_KEY="ZNh-14foqIiscbz24oKwww"'
- apollo_key=8ku3EoDJxz8fOSCdxYozdA
- apollo.io api_key oD8GCL8MNZIyg0tzeSDuhw
references:
- https://docs.apollo.io/reference/people-api-search
validation:
type: Http
content:
request:
method: POST
url: "https://api.apollo.io/api/v1/mixed_people/api_search"
headers:
accept: "application/json"
content-type: "application/json"
x-api-key: "{{ TOKEN }}"
body: |
{"page":1,"per_page":1}
response_matcher:
- report_response: true
- type: StatusMatch
status: [200, 403]
- type: WordMatch
words:
- '"total_entries"'
- '"API_INACCESSIBLE"'
match_all_words: false
- type: WordMatch
negative: true
words:
- '"Invalid access credentials"'

View file

@ -6,16 +6,17 @@ rules:
(?:
# A) Connection string: AccountName=<name>
(?i:AccountName)\s*=\s*([a-z0-9]{3,24})(?:\b|[^a-z0-9])
|
# B) Blob endpoint URL: <name>.blob.core.windows.net
([a-z0-9]{3,24})\.blob\.core\.windows\.net\b
|
# C) Explicit KV labels near 'azure storage/account name' with tight separators
\bazure(?:[_\s-]*)(?:storage|account)(?:[_\s-]*)(?:name)\b
[\s:=\"']{0,6}
([a-z0-9]{3,24})(?:\b|[^a-z0-9])
|
# D) Explicit KV labels near 'azure storage/account name' with tight separators
(?i:Account[_.-]?Name|Storage[_.-]?(?:Name))(?:.|\s){0,32}?\b([A-Z0-9]{3,32})\b|([A-Z0-9]{3,32})(?i:\.blob\.core\.windows\.net)
)
min_entropy: 2.0
visible: false
@ -28,7 +29,6 @@ rules:
id: kingfisher.azurestorage.2
pattern: |
(?xi)
\b
azure
(?:.|[\n\r]){0,128}?
(?:SECRET|PRIVATE|ACCESS|KEY|TOKEN)

View file

@ -10,7 +10,6 @@ rules:
(
b_[A-Z0-9=_\\/\\\-+]{44}
)
\b
pattern_requirements:
min_digits: 2
min_uppercase: 1

25
data/rules/clay.yml Normal file
View file

@ -0,0 +1,25 @@
rules:
- name: Clay API Key
id: kingfisher.clay.1
pattern: |
(?xi)
\b
clay
(?:.|[\n\r]){0,64}?
\b
(
[a-f0-9]{20}
)
\b
pattern_requirements:
min_digits: 6
min_entropy: 3.0
confidence: medium
examples:
- clay_api_key=ce1abceaffe7d7958a41
- "CLAY_KEY: bdc55270455ca0a892e4"
- export CLAY_TOKEN=e9b711a5acbb99b8f099
- 'clay key: f6fd04ab6b4f7992adc2'
- CLAY_API_KEY=d8dfd14ec83e4e17a7d2
references:
- https://university.clay.com/docs/http-api-integration-overview

39
data/rules/coderabbit.yml Normal file
View file

@ -0,0 +1,39 @@
rules:
- name: CodeRabbit API Key
id: kingfisher.coderabbit.1
pattern: |
(?xi)
\b
(
cr-[a-f0-9]{58}
)
\b
pattern_requirements:
min_digits: 4
min_entropy: 3.5
confidence: medium
examples:
- "cr-33420bb12fddf6cde6fba5414df88b07f75b2258e30c956b95f2ddbb2d"
references:
- https://coderabbit.ai/
- https://api.coderabbit.ai/docs
validation:
type: Http
content:
request:
method: GET
url: "https://api.coderabbit.ai/v1/seats/"
headers:
accept: "application/json"
x-coderabbitai-api-key: "{{TOKEN}}"
response_matcher:
- report_response: true
- type: WordMatch
words:
- '"success"'
- '"errors"'
match_all_words: false
- type: WordMatch
negative: true
words:
- '"Invalid or inactive API key"'

68
data/rules/customerio.yml Normal file
View file

@ -0,0 +1,68 @@
rules:
- name: Customer.io Tracking API Key
id: kingfisher.customerio.1
pattern: |
(?xi)
\b
(?:customer(?:\.?io)?|customerio|cio|tracking|track)
(?:.|[\n\r]){0,32}?
(?:SECRET|PRIVATE|ACCESS|KEY|TOKEN|API[_-]?KEY)
(?:.|[\n\r]){0,16}?
\b
(
[0-9a-f]{20}
)
\b
pattern_requirements:
min_digits: 4
min_entropy: 3.0
confidence: medium
examples:
- "tracking api key: f3b0c2b92eca01472efe"
- "customerio_key = a98eab982f4692ceb78f"
- "customer.io tracking_api_key d24d3915959b4d793a67"
references:
- https://docs.customer.io/integrations/api/#track-api
- name: Customer.io App API Key
id: kingfisher.customerio.2
pattern: |
(?xi)
\b
(?:customer(?:\.?io)?|customerio|cio)
(?:.|[\n\r]){0,32}?
(?:SECRET|PRIVATE|ACCESS|KEY|TOKEN|API)
(?:.|[\n\r]){0,16}?
\b
(
[0-9a-f]{32}
)
\b
pattern_requirements:
min_digits: 6
min_entropy: 3.0
confidence: medium
examples:
- "customerio_app_key=6e86f5734527548b7477a8b627bf4855"
- "customer.io api key 8363e3ca7e897cae7d76b8f46632e155"
- "cio_app_key: 801b93d4c8627282bbd3524362f1ea9d"
references:
- https://docs.customer.io/integrations/api/#app-api
- https://api.customer.io/v1/workspaces
validation:
type: Http
content:
request:
method: GET
url: https://api.customer.io/v1/workspaces
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- report_response: true
- type: StatusMatch
status: [200]
- type: JsonValid
- type: WordMatch
match_all_words: true
words:
- '"workspaces"'

View file

@ -42,6 +42,5 @@ rules:
- report_response: true
- type: StatusMatch
status: [200]
- type: JsonValid
- type: WordMatch
words: ['"answer"']

View file

@ -5,13 +5,13 @@ rules:
(?xi)
\b
(
gsk_[a-zA-Z0-9]{52}
gsk_[A-Z0-9]{52}
)
\b
pattern_requirements:
min_digits: 2
min_digits: 4
confidence: medium
min_entropy: 4.0
min_entropy: 3.5
validation:
type: Http
content:

41
data/rules/instantly.yml Normal file
View file

@ -0,0 +1,41 @@
rules:
- name: Instantly API Key
id: kingfisher.instantly.1
pattern: |
(?xi)
\b
instantly
(?:\.ai)?
(?:.|[\n\r]){0,16}?
(?:SECRET|PRIVATE|ACCESS|KEY|TOKEN)
(?:.|[\n\r]){0,16}?
\b
(
[A-Z0-9+/]{66}==
)
pattern_requirements:
min_digits: 4
min_entropy: 3.3
confidence: medium
examples:
- 'INSTANTLY_API_KEY="NmNlMCI1MWUtZDBmMC00NTc4LWE0MDItMDM0NGU0ZWI0MzliOmFzWWtCZUxUY3ZPRg=="'
references:
- https://developer.instantly.ai/api/v2/analytics/getdailyaccountanalytics
validation:
type: Http
content:
request:
method: GET
url: "https://api.instantly.ai/api/v2/accounts/analytics/daily?start_date={{ '' | date: '%Y-%m-01' }}&end_date={{ '' | date: '%Y-%m-%d' }}"
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- report_response: true
- type: StatusMatch
status: [200, 401]
- type: WordMatch
negative: true
words:
- '"Invalid authorization header or API key"'
- '"Invalid API key"'
- type: JsonValid

View file

@ -3,9 +3,11 @@ rules:
id: kingfisher.openai.1
pattern: |
(?xi)
\b
(
sk-[A-Z0-9]{48}
)
\b
pattern_requirements:
min_digits: 2
min_entropy: 3.3
@ -33,6 +35,7 @@ rules:
id: kingfisher.openai.2
pattern: |
(?xi)
\b
(
(sk-(?:proj|svcacct|None)-[A-Z0-9_-]{100,})
)
@ -65,9 +68,11 @@ rules:
id: kingfisher.openai.3
pattern: |
(?xi)
\b
(
sk-None-[A-Z0-9]{48}
)
\b
pattern_requirements:
min_digits: 2
min_entropy: 3.3

44
data/rules/vastai.yml Normal file
View file

@ -0,0 +1,44 @@
rules:
- name: Vast.ai API Key
id: kingfisher.vastai.1
pattern: |
(?xi)
\b
vast(?:\.ai)?
(?:.|[\n\r]){0,16}?
(?:SECRET|PRIVATE|ACCESS|KEY|TOKEN)
(?:.|[\n\r]){0,16}?
\b
(
[a-f0-9]{64}
)
\b
pattern_requirements:
min_digits: 8
min_entropy: 3.5
confidence: medium
examples:
- VAST_API_KEY=c218521a7eaf108227795ae059866db8fdd7be16348f67ec48e66af4b2df4a76
- 'vastai_access_key: c218521a7eaf108227795ae059866db8fdd7be16348f67ec48e66af4b2df4a76'
references:
- https://docs.vast.ai/api-reference/accounts/show-user
validation:
type: Http
content:
request:
method: GET
url: https://console.vast.ai/api/v0/users/current/
headers:
Authorization: "Bearer {{ TOKEN }}"
Accept: application/json
response_matcher:
- report_response: true
- type: StatusMatch
status: [200]
- type: JsonValid
- type: WordMatch
match_all_words: true
words:
- '"id"'
- '"email"'
- '"balance"'

View file

@ -267,6 +267,7 @@ function normalizeAccessMap(entries = []) {
return entries.map((entry) => ({
provider: entry.provider,
account: entry.account,
fingerprint: entry.fingerprint,
groups: (entry.groups || []).map((group) => ({
resources: Array.isArray(group.resources) ? group.resources : [],
permissions: Array.isArray(group.permissions) ? group.permissions : [],
@ -277,17 +278,18 @@ function normalizeAccessMap(entries = []) {
return entries.map((entry) => ({
provider: entry.provider,
account: entry.account,
fingerprint: entry.fingerprint,
groups: [
{
resources: entry.resource ? [entry.resource] : [],
permissions: Array.isArray(entry.permissions)
? entry.permissions
: entry.permission
? String(entry.permission)
? String(entry.permission)
.split(",")
.map((p) => p.trim())
.filter(Boolean)
: [],
: [],
},
],
}));
@ -301,6 +303,7 @@ function flattenAccessMap(entries = []) {
rows.push({
provider: entry.provider,
account: entry.account,
fingerprint: entry.fingerprint,
resource,
permissions: group.permissions || [],
});
@ -364,7 +367,7 @@ function filteredFindings() {
if (validationFilter === "not_attempted" && f.validationStatus.toLowerCase() !== "not attempted") return false;
if (!currentFilter) return true;
const haystack = `${f.ruleId} ${f.ruleName} ${f.findingType} ${f.message} ${f.path} ${f.validationStatus}`.toLowerCase();
const haystack = `${f.ruleId} ${f.ruleName} ${f.findingType} ${f.message} ${f.path} ${f.validationStatus} ${f.fingerprint}`.toLowerCase();
return haystack.includes(currentFilter);
})
.sort((a, b) => {
@ -512,7 +515,7 @@ function downloadJson() {
function copyAccessMap() {
if (!accessMap.length) return;
const text = JSON.stringify(accessMap, null, 2);
navigator.clipboard.writeText(text).catch(() => {});
navigator.clipboard.writeText(text).catch(() => { });
}
function exportCsv() {

View file

@ -718,6 +718,11 @@
border-color: var(--border);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#theme-toggle {
background: #f9fafb;
color: var(--brand);
@ -853,8 +858,16 @@
Findings
</button>
</div>
<div class="nav-group">
<button class="btn" id="download-pdf" type="button" style="width:100%;">Download PDF report</button>
<div class="nav-group" style="display:flex; flex-direction:column; gap:10px;">
<button class="btn" id="download-findings-report" type="button" style="width:100%;">Download Findings Report</button>
<div style="display:flex; flex-direction:column; gap:6px;">
<span style="font-size:11px; text-transform:uppercase; letter-spacing:0.05em; color:var(--text-muted);">Findings scope</span>
<select id="report-scope" class="rows-select">
<option value="all" selected>All findings</option>
<option value="filtered">Filtered findings</option>
</select>
</div>
<button class="btn" id="download-access-report" type="button" style="width:100%;" disabled>Download Access Map Report</button>
</div>
</aside>
@ -878,10 +891,6 @@
<div class="metric__label">Identities Mapped</div>
<div class="metric__value" id="stat-identities">0</div>
</div>
<div class="metric">
<div class="metric__label">Scan Duration</div>
<div class="metric__value" id="stat-duration" style="font-size:20px; margin-top:8px;">-</div>
</div>
</div>
</section>
@ -1102,10 +1111,18 @@
<label>Entropy</label>
<div id="fd-entropy"></div>
</div>
<div class="detail-field">
<label>Validation Status</label>
<div id="fd-validation-status"></div>
</div>
<div class="detail-field">
<label>Git Commit</label>
<div id="fd-commit"></div>
</div>
<div class="detail-field" id="fd-committer-email-wrapper">
<label>Committer Email</label>
<div id="fd-committer-email"></div>
</div>
<div class="detail-field">
<label>File Path</label>
<textarea id="fd-path" class="path-area" readonly></textarea>
@ -1153,7 +1170,9 @@
const errorMsg = document.getElementById("error-msg");
const uploadSection = document.getElementById("upload-section");
const dashboard = document.getElementById("dashboard");
const downloadPdfBtn = document.getElementById("download-pdf");
const downloadFindingsReportBtn = document.getElementById("download-findings-report");
const reportScopeSelect = document.getElementById("report-scope");
const downloadAccessReportBtn = document.getElementById("download-access-report");
const searchInput = document.getElementById("search-input");
const validationSelect = document.getElementById("validation-filter");
@ -1273,8 +1292,14 @@
downloadJsonBtn.addEventListener("click", downloadFindingsJson);
downloadCsvBtn.addEventListener("click", downloadFindingsCsv);
copyAccessMapButton.addEventListener("click", copyFilteredAccessMap);
if (downloadPdfBtn) {
downloadPdfBtn.addEventListener("click", generatePdfReport);
if (downloadFindingsReportBtn) {
downloadFindingsReportBtn.addEventListener("click", () => {
const scope = reportScopeSelect ? reportScopeSelect.value : "all";
generatePdfReport(scope);
});
}
if (downloadAccessReportBtn) {
downloadAccessReportBtn.addEventListener("click", generateAccessMapReport);
}
if (themeToggle) {
themeToggle.addEventListener("click", () => {
@ -1333,6 +1358,7 @@
function syncAccessMapUi(hasAccess) {
const previouslyAutoCollapsed = autoCollapsedAccessMap;
if (amToggle) amToggle.disabled = !hasAccess;
if (downloadAccessReportBtn) downloadAccessReportBtn.disabled = !hasAccess;
if (amEmptyNotice) {
amEmptyNotice.classList.toggle("hidden", hasAccess);
}
@ -1583,10 +1609,6 @@
document.getElementById("stat-identities").textContent = (accessMap || []).length.toString();
const durEl = document.getElementById("stat-duration");
const scanSeconds = resolveScanDurationSeconds(rawData);
durEl.textContent = formatDurationText(scanSeconds);
renderStatusChart(validationCounts);
}
@ -1600,12 +1622,13 @@
const ruleName = (rule.name || rule.id || "").toLowerCase();
const path = (finding.path || "").toLowerCase();
const snippet = (finding.snippet || "").toLowerCase();
const fingerprint = (finding.fingerprint || "").toLowerCase();
const status = (finding.validation && finding.validation.status
? String(finding.validation.status)
: "").toLowerCase();
if (filterLower) {
if (!ruleName.includes(filterLower) && !path.includes(filterLower) && !snippet.includes(filterLower)) {
if (!ruleName.includes(filterLower) && !path.includes(filterLower) && !snippet.includes(filterLower) && !fingerprint.includes(filterLower)) {
return false;
}
}
@ -1713,9 +1736,9 @@
return "unknown";
}
function calculateValidationCounts() {
function calculateValidationCounts(list = findings) {
const counts = { active: 0, inactive: 0, not_attempted: 0, unknown: 0 };
findings.forEach((f) => {
(list || []).forEach((f) => {
const status =
f.finding && f.finding.validation && f.finding.validation.status
? f.finding.validation.status
@ -1726,50 +1749,6 @@
return counts;
}
function resolveScanDurationSeconds(data) {
if (!data) return null;
const candidates = [
data.scan_duration,
data.scanDuration,
data.duration_seconds,
data.duration,
data.stats && data.stats.scan_duration,
data.stats && data.stats.scanDuration,
data.summary && data.summary.scan_duration,
];
for (const candidate of candidates) {
const parsed = parseDuration(candidate);
if (parsed != null) return parsed;
}
return null;
}
function parseDuration(value) {
if (value === undefined || value === null) return null;
if (typeof value === "number" && Number.isFinite(value)) return value;
const numeric = Number(value);
if (Number.isFinite(numeric)) return numeric;
if (typeof value === "string") {
const match = value.match(/([\d.]+)\s*s/i);
if (match) {
const parsed = Number(match[1]);
if (Number.isFinite(parsed)) return parsed;
}
}
return null;
}
function formatDurationText(seconds) {
if (seconds === null || seconds === undefined) return "-";
const value = Number(seconds);
if (!Number.isFinite(value)) return "-";
if (value < 1) return value.toFixed(3) + "s";
return value.toFixed(2) + "s";
}
function renderStatusChart(counts) {
if (!statusChartCanvas) return;
const ctx = statusChartCanvas.getContext("2d");
@ -1929,14 +1908,105 @@
triggerDownload("kingfisher-findings.csv", csv, "text/csv");
}
function generatePdfReport() {
function getFindingIdFromFinding(finding) {
if (!finding) return "";
return (
finding.id ||
finding.finding_id ||
finding.findingId ||
finding.fingerprint ||
""
);
}
function getFindingIdFromAccessEntry(entry) {
if (!entry) return "";
return (
entry.finding_id ||
entry.findingId ||
entry.finding ||
entry.fingerprint ||
""
);
}
function getTokenNameFromAccessEntry(entry) {
if (!entry) return "";
return (
entry.token_name ||
entry.tokenName ||
(entry.token_details && entry.token_details.name) ||
(entry.token && entry.token.name) ||
""
);
}
function getUserIdFromAccessEntry(entry) {
if (!entry) return "";
return (
entry.user_id ||
entry.userId ||
(entry.token_details && entry.token_details.user_id) ||
(entry.token && entry.token.user_id) ||
""
);
}
function buildAccessMapListHtml({ includeMeta = false } = {}) {
if (!Array.isArray(accessMap) || accessMap.length === 0) {
return "";
}
return accessMap
.map((entry) => {
const groups = Array.isArray(entry.groups) ? entry.groups : [];
const findingId = getFindingIdFromAccessEntry(entry);
const tokenName = getTokenNameFromAccessEntry(entry);
const userId = getUserIdFromAccessEntry(entry);
const metaLine = includeMeta
? `<div style="font-size:12px; color:#475569; margin-top:4px;">
<strong>Finding ID:</strong> ${escapeHtml(findingId || "-")} ·
<strong>Token Name:</strong> ${escapeHtml(tokenName || "-")} ·
<strong>User ID:</strong> ${escapeHtml(userId || "-")}
</div>`
: "";
const groupList = groups
.map((g) => {
const resList = Array.isArray(g.resources) && g.resources.length
? g.resources.map((r) => escapeHtml(String(r))).join(", ")
: "No resources listed";
const permList = Array.isArray(g.permissions) && g.permissions.length
? g.permissions.map((p) => escapeHtml(String(p))).join(", ")
: "No permissions listed";
return `<li><strong>Resources:</strong> ${resList}<br><strong>Permissions:</strong> ${permList}</li>`;
})
.join("");
return `
<li class="access-entry">
<div class="access-head">
${escapeHtml(entry.account || "(identity)")}
<span class="tag">${escapeHtml((entry.provider || "Unknown").toUpperCase())}</span>
</div>
${metaLine}
<ul class="access-groups">
${groupList || "<li>No resources recorded.</li>"}
</ul>
</li>
`;
})
.join("");
}
function generatePdfReport(scope = "all") {
if (!rawData) {
alert("Load a report before downloading a PDF report.");
alert("Load a report before downloading a Findings report.");
return;
}
const statusOrder = { active: 0, inactive: 1, not_attempted: 2, unknown: 3 };
const findingsForReport = Array.isArray(findings) ? findings.slice() : [];
const baseFindings =
scope === "filtered" ? getFilteredSortedFindings().slice() : (Array.isArray(findings) ? findings.slice() : []);
const findingsForReport = baseFindings.slice();
findingsForReport.sort((a, b) => {
const fa = a.finding || {};
const fb = b.finding || {};
@ -1951,15 +2021,14 @@
return ra.localeCompare(rb);
});
const counts = calculateValidationCounts();
const durationSeconds = resolveScanDurationSeconds(rawData);
const durationText = formatDurationText(durationSeconds);
const counts = calculateValidationCounts(baseFindings);
const hasAccess = Array.isArray(accessMap) && accessMap.length > 0;
const statusImage = statusChartCanvas ? statusChartCanvas.toDataURL("image/png") : "";
const highConfidence = findings.filter((f) => {
const statusImage = scope === "all" && statusChartCanvas ? statusChartCanvas.toDataURL("image/png") : "";
const highConfidence = baseFindings.filter((f) => {
const conf = f.finding && f.finding.confidence ? String(f.finding.confidence) : "";
return conf.toLowerCase() === "high";
}).length;
const scopeLabel = scope === "filtered" ? "Filtered findings" : "All findings";
const findingsHtml = findingsForReport.length
? findingsForReport
@ -1968,49 +2037,26 @@
const finding = entry.finding || {};
const statusRaw = finding.validation && finding.validation.status ? finding.validation.status : "Unknown";
const status = normalizeValidationStatus(statusRaw);
const findingId = getFindingIdFromFinding(finding);
const gitUrl = getFileUrlFromFinding(finding);
const snippet = (finding.snippet || "").toString().replace(/\s+/g, " ").trim();
return `
<tr>
<td>${escapeHtml(rule.name || rule.id || "")}</td>
<td>${escapeHtml(findingId || "")}</td>
<td>${escapeHtml(finding.path || "")}</td>
<td>${escapeHtml(statusRaw)}</td>
<td>${escapeHtml(finding.confidence || "")}</td>
<td>${finding.line != null ? escapeHtml(finding.line) : ""}</td>
<td>${escapeHtml(gitUrl || "")}</td>
<td>${escapeHtml(snippet.slice(0, 200))}</td>
</tr>
`;
})
.join("")
: '<tr><td colspan="6">No findings available.</td></tr>';
: '<tr><td colspan="7">No findings available.</td></tr>';
const accessListHtml = hasAccess
? accessMap
.map((entry) => {
const groups = Array.isArray(entry.groups) ? entry.groups : [];
const groupList = groups
.map((g) => {
const resList = Array.isArray(g.resources) && g.resources.length
? g.resources.map((r) => escapeHtml(String(r))).join(", ")
: "No resources listed";
const permList = Array.isArray(g.permissions) && g.permissions.length
? g.permissions.map((p) => escapeHtml(String(p))).join(", ")
: "No permissions listed";
return `<li><strong>Resources:</strong> ${resList}<br><strong>Permissions:</strong> ${permList}</li>`;
})
.join("");
return `
<li class="access-entry">
<div class="access-head">
${escapeHtml(entry.account || "(identity)")}
<span class="tag">${escapeHtml((entry.provider || "Unknown").toUpperCase())}</span>
</div>
<ul class="access-groups">
${groupList || "<li>No resources recorded.</li>"}
</ul>
</li>
`;
})
.join("")
? buildAccessMapListHtml()
: "";
const accessSectionContent = hasAccess
@ -2024,14 +2070,15 @@
<meta charset="UTF-8">
<title>Kingfisher Report</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 24px; color: #0f172a; }
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 16px; color: #0f172a; }
h1, h2, h3 { margin: 0 0 12px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 18px; }
.stat { padding: 12px; border: 1px solid #d1d5db; border-radius: 8px; background: #f8fafc; }
.stat .label { font-size: 12px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; }
.stat .value { font-size: 22px; font-weight: 700; margin-top: 6px; }
table { width: 100%; border-collapse: collapse; margin-top: 8px; }
th, td { border: 1px solid #d1d5db; padding: 8px 10px; font-size: 13px; text-align: left; }
table { width: 100%; border-collapse: collapse; margin-top: 8px; table-layout: fixed; }
th, td { border: 1px solid #d1d5db; padding: 6px 8px; font-size: 11px; text-align: left; word-break: break-word; overflow-wrap: anywhere; }
th { background: #e5e7eb; }
.section { margin-bottom: 28px; }
.tag { display: inline-block; padding: 2px 8px; border-radius: 999px; background: #e0f2fe; color: #075985; font-weight: 700; font-size: 12px; margin-left: 8px; }
@ -2048,16 +2095,15 @@
</head>
<body>
<h1>Kingfisher Report</h1>
<div class="meta">Generated ${escapeHtml(new Date().toLocaleString())}</div>
<div class="meta">Generated ${escapeHtml(new Date().toLocaleString())} · Scope: ${escapeHtml(scopeLabel)}</div>
<div class="section">
<h2>Dashboard</h2>
<div class="grid">
<div class="stat"><div class="label">Total Findings</div><div class="value">${findings.length}</div></div>
<div class="stat"><div class="label">Total Findings</div><div class="value">${baseFindings.length}</div></div>
<div class="stat"><div class="label">High Confidence</div><div class="value">${highConfidence}</div></div>
<div class="stat"><div class="label">Active Credentials</div><div class="value">${counts.active || 0}</div></div>
<div class="stat"><div class="label">Identities Mapped</div><div class="value">${accessMap.length}</div></div>
<div class="stat"><div class="label">Scan Duration</div><div class="value">${durationText}</div></div>
</div>
<div class="chart-wrapper">
${statusImage ? `<img src="${statusImage}" alt="Status chart" style="max-width:280px; border:1px solid #d1d5db; border-radius:10px;">` : ""}
@ -2071,20 +2117,16 @@
</div>
<div class="section">
<h2>Access Map</h2>
${accessSectionContent}
</div>
<div class="section">
<h2>Findings (Active first)</h2>
<h2>${escapeHtml(scope === "filtered" ? "Findings (Filtered, Active first)" : "Findings (Active first)")}</h2>
<table>
<thead>
<tr>
<th>Rule</th>
<th>Finding ID</th>
<th>File Path</th>
<th>Status</th>
<th>Confidence</th>
<th>Line</th>
<th>Git URL</th>
<th>Snippet</th>
</tr>
</thead>
@ -2093,13 +2135,66 @@
</tbody>
</table>
</div>
</body>
</html>
`;
const win = window.open("", "_blank", "width=1200,height=900");
if (!win) {
alert("Please allow pop-ups to download the PDF report.");
alert("Please allow pop-ups to download the Findings report.");
return;
}
win.document.write(pdfHtml);
win.document.close();
win.focus();
setTimeout(() => win.print(), 400);
}
function generateAccessMapReport() {
if (!rawData) {
alert("Load a report before downloading an Access Map report.");
return;
}
const accessListHtml = buildAccessMapListHtml({ includeMeta: true });
if (!accessListHtml) {
alert("No Access Map entries are available for this report.");
return;
}
const pdfHtml = `
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<title>Kingfisher Access Map Report</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 16px; color: #0f172a; }
h1, h2 { margin: 0 0 12px; }
.meta { color: #4b5563; font-size: 13px; margin-bottom: 14px; }
.tag { display: inline-block; padding: 2px 8px; border-radius: 999px; background: #e0f2fe; color: #075985; font-weight: 700; font-size: 12px; margin-left: 8px; }
.access-list { list-style: none; padding-left: 0; margin: 0; display: flex; flex-direction: column; gap: 10px; }
.access-entry { border: 1px solid #d1d5db; border-radius: 8px; padding: 10px; background: #f8fafc; }
.access-head { font-weight: 700; font-size: 14px; margin-bottom: 6px; }
.access-groups { margin: 0; padding-left: 16px; color: #1f2937; }
</style>
</head>
<body>
<h1>Kingfisher Access Map Report</h1>
<div class="meta">Generated ${escapeHtml(new Date().toLocaleString())}</div>
<div class="section">
<h2>Access Map</h2>
<ul class="access-list">${accessListHtml}</ul>
</div>
</body>
</html>
`;
const win = window.open("", "_blank", "width=1200,height=900");
if (!win) {
alert("Please allow pop-ups to download the Access Map report.");
return;
}
win.document.write(pdfHtml);
@ -2191,7 +2286,33 @@
panel.scrollIntoView({ behavior: "smooth", block: "start" });
document.getElementById("fd-rule-id").textContent = rule.id || "";
document.getElementById("fd-fingerprint").textContent = finding.fingerprint || "";
const fpEl = document.getElementById("fd-fingerprint");
const fpVal = finding.fingerprint || "";
fpEl.textContent = fpVal;
fpEl.innerHTML = fpVal; // reset content
if (fpVal && Array.isArray(accessMap)) {
const hasEntry = accessMap.some(entry => entry.fingerprint === fpVal);
if (hasEntry) {
const btn = document.createElement("button");
btn.className = "badge";
btn.textContent = "Go to Access Map";
btn.style.marginLeft = "10px";
btn.style.cursor = "pointer";
btn.style.background = "var(--brand-soft)";
btn.style.borderColor = "var(--brand)";
btn.style.color = "var(--brand-dark)";
btn.onclick = () => {
setActiveView("view-access");
const treeSearch = document.getElementById("tree-search");
if (treeSearch) {
treeSearch.value = fpVal;
treeSearch.dispatchEvent(new Event('input'));
}
};
fpEl.appendChild(btn);
}
}
document.getElementById("fd-entropy").textContent =
finding.entropy != null ? String(finding.entropy) : "";
const commit =
@ -2200,6 +2321,39 @@
: "N/A";
document.getElementById("fd-commit").textContent = commit;
const committerWrapper = document.getElementById("fd-committer-email-wrapper");
const committerEmailEl = document.getElementById("fd-committer-email");
const committerEmail =
finding.git_metadata &&
finding.git_metadata.commit &&
finding.git_metadata.commit.committer &&
finding.git_metadata.commit.committer.email
? String(finding.git_metadata.commit.committer.email)
: "";
if (committerWrapper && committerEmailEl) {
if (committerEmail) {
committerWrapper.style.display = "";
committerEmailEl.textContent = committerEmail;
} else {
committerWrapper.style.display = "none";
committerEmailEl.textContent = "";
}
}
const statusRaw =
finding.validation && finding.validation.status ? String(finding.validation.status) : "Unknown";
const normalizedStatus = normalizeValidationStatus(statusRaw);
const badgeClass =
normalizedStatus === "active"
? "active"
: normalizedStatus === "inactive"
? "inactive"
: "unknown";
const statusEl = document.getElementById("fd-validation-status");
if (statusEl) {
statusEl.innerHTML = `<span class="status-badge ${badgeClass}">${escapeHtml(statusRaw)}</span>`;
}
const path = finding.path || "";
if (fdPathInput) {
fdPathInput.value = path || "—";
@ -2332,7 +2486,8 @@
const account = formatIdentityLabel(identity);
const groups = Array.isArray(identity.groups) ? identity.groups : [];
const identityNameMatches = Boolean(filterLower) && account.toLowerCase().includes(filterLower);
const fingerprint = (identity.fingerprint || "").toLowerCase();
const identityNameMatches = Boolean(filterLower) && (account.toLowerCase().includes(filterLower) || fingerprint.includes(filterLower));
let anyResourceMatches = false;
const preparedGroups = groups.map((group) => {
@ -2689,6 +2844,41 @@
if (data.provider) {
addBadge(meta, provider, providerBadgeClass(data.provider));
}
if (data.fingerprint) {
const fpDiv = document.createElement("div");
fpDiv.style.width = "100%";
fpDiv.style.marginTop = "6px";
fpDiv.style.fontSize = "11px";
fpDiv.style.color = "var(--text-muted)";
fpDiv.textContent = "Fingerprint: " + data.fingerprint;
meta.appendChild(fpDiv);
const btn = document.createElement("button");
btn.className = "badge";
btn.textContent = "Go to finding";
btn.style.marginTop = "6px";
btn.style.cursor = "pointer";
btn.style.background = "var(--brand-soft)";
btn.style.borderColor = "var(--brand)";
btn.style.color = "var(--brand-dark)";
btn.onclick = () => {
const searchInput = document.getElementById("search-input");
if (searchInput) {
setActiveView("view-findings");
searchInput.value = data.fingerprint;
currentFilter = data.fingerprint;
currentPage = 1;
renderFindingsTable();
setTimeout(() => {
const tableContainer = document.querySelector('.table-container');
if (tableContainer) {
tableContainer.scrollIntoView({behavior: 'smooth', block: 'start'});
}
}, 50);
}
};
meta.appendChild(btn);
}
if (data.token_details) {
const details = data.token_details;
tokenName.textContent = details.name || "-";
@ -2758,4 +2948,4 @@
}
</script>
</body>
</html>
</html>

View file

@ -11,6 +11,7 @@ mod gcp;
mod github;
mod gitlab;
mod report;
mod slack;
/// Run the identity mapping workflow for the selected cloud provider.
pub async fn run(args: AccessMapArgs) -> Result<()> {
@ -20,6 +21,7 @@ pub async fn run(args: AccessMapArgs) -> Result<()> {
AccessMapProvider::Azure => azure::map_access(&args).await?,
AccessMapProvider::Github => github::map_access(&args).await?,
AccessMapProvider::Gitlab => gitlab::map_access(&args).await?,
AccessMapProvider::Slack => slack::map_access(&args).await?,
};
let json = serde_json::to_string_pretty(&result)?;
@ -40,17 +42,24 @@ pub async fn run(args: AccessMapArgs) -> Result<()> {
#[derive(Clone, Debug)]
pub enum AccessMapRequest {
/// AWS access key credentials.
Aws { access_key: String, secret_key: String, session_token: Option<String> },
Aws {
access_key: String,
secret_key: String,
session_token: Option<String>,
fingerprint: String,
},
/// A GCP service account JSON document.
Gcp { credential_json: String },
Gcp { credential_json: String, fingerprint: String },
/// An Azure storage account JSON document.
Azure { credential_json: String, containers: Option<Vec<String>> },
Azure { credential_json: String, containers: Option<Vec<String>>, fingerprint: String },
/// An Azure DevOps personal access token with organization.
AzureDevops { token: String, organization: String },
AzureDevops { token: String, organization: String, fingerprint: String },
/// A GitHub token.
Github { token: String },
Github { token: String, fingerprint: String },
/// A GitLab token.
Gitlab { token: String },
Gitlab { token: String, fingerprint: String },
/// A Slack token.
Slack { token: String, fingerprint: String },
}
/// Structured output describing the resolved identity and its risk profile.
@ -59,6 +68,9 @@ pub struct AccessMapResult {
/// Cloud name such as "gcp", "aws", or "azure".
pub cloud: String,
/// Unique fingerprint of the finding.
pub fingerprint: Option<String>,
/// Summary of the resolved identity.
pub identity: AccessSummary,
@ -183,35 +195,56 @@ pub async fn map_requests(requests: Vec<AccessMapRequest>) -> Vec<AccessMapResul
let mut results = Vec::new();
for request in requests {
let mapped = match request {
AccessMapRequest::Aws { access_key, secret_key, session_token } => {
aws::map_access_with_credentials(&access_key, &secret_key, session_token.as_deref())
.await
.unwrap_or_else(|err| build_failed_result("aws", &access_key, err))
}
AccessMapRequest::Gcp { credential_json } => {
let (mut mapped, fp) = match request {
AccessMapRequest::Aws { access_key, secret_key, session_token, fingerprint } => (
aws::map_access_with_credentials(
&access_key,
&secret_key,
session_token.as_deref(),
)
.await
.unwrap_or_else(|err| build_failed_result("aws", &access_key, err)),
fingerprint,
),
AccessMapRequest::Gcp { credential_json, fingerprint } => (
gcp::map_access_from_json(&credential_json)
.await
.unwrap_or_else(|err| build_failed_result("gcp", "service_account", err))
}
AccessMapRequest::Azure { credential_json, containers } => {
.unwrap_or_else(|err| build_failed_result("gcp", "service_account", err)),
fingerprint,
),
AccessMapRequest::Azure { credential_json, containers, fingerprint } => (
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 } => {
.unwrap_or_else(|err| build_failed_result("azure", "storage_account", err)),
fingerprint,
),
AccessMapRequest::AzureDevops { token, organization, fingerprint } => (
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)),
.unwrap_or_else(|err| build_failed_result("azure_devops", "pat", err)),
fingerprint,
),
AccessMapRequest::Github { token, fingerprint } => (
github::map_access_from_token(&token)
.await
.unwrap_or_else(|err| build_failed_result("github", "token", err)),
fingerprint,
),
AccessMapRequest::Gitlab { token, fingerprint } => (
gitlab::map_access_from_token(&token)
.await
.unwrap_or_else(|err| build_failed_result("gitlab", "token", err)),
fingerprint,
),
AccessMapRequest::Slack { token, fingerprint } => (
slack::map_access_from_token(&token)
.await
.unwrap_or_else(|err| build_failed_result("slack", "token", err)),
fingerprint,
),
};
mapped.fingerprint = Some(fp);
results.push(mapped);
}
@ -251,6 +284,7 @@ fn build_failed_result(cloud: &str, identity_label: &str, err: anyhow::Error) ->
risk_notes: vec![format!("Identity mapping failed: {err}")],
token_details: None,
provider_metadata: None,
fingerprint: None,
}
}

View file

@ -154,6 +154,7 @@ async fn map_access_with_config(config: SdkConfig) -> Result<AccessMapResult> {
scopes: Vec::new(),
}),
provider_metadata: None,
fingerprint: None,
})
}

View file

@ -106,6 +106,7 @@ pub async fn map_access_from_json_with_hints(
risk_notes,
token_details: None,
provider_metadata: None,
fingerprint: None,
})
}

View file

@ -262,6 +262,7 @@ pub async fn map_access_from_token(token: &str, organization: &str) -> Result<Ac
scopes: token_scopes,
}),
provider_metadata: None,
fingerprint: None,
})
}

View file

@ -201,6 +201,7 @@ pub async fn map_access_from_json(data: &str) -> Result<AccessMapResult> {
risk_notes,
token_details: None,
provider_metadata: None,
fingerprint: None,
})
}

View file

@ -282,6 +282,7 @@ pub async fn map_access_from_token(token: &str) -> Result<AccessMapResult> {
scopes: oauth_scopes.clone(),
}),
provider_metadata: None,
fingerprint: None,
})
}

View file

@ -179,6 +179,7 @@ pub async fn map_access_from_token(token: &str) -> Result<AccessMapResult> {
token_details,
provider_metadata: metadata
.map(|info| ProviderMetadata { version: info.version, enterprise: info.enterprise }),
fingerprint: None,
})
}

View file

@ -1,48 +0,0 @@
use super::AccessMapResult;
/// Convert an identity map result into a Graphviz DOT representation.
pub fn to_dot(result: &AccessMapResult) -> String {
let mut out = String::new();
out.push_str("digraph G {\n rankdir=LR;\n");
out.push_str(&format!(
" identity [label=\"{} ({})\"];\n",
result.identity.id, result.identity.access_type
));
for role in &result.roles {
let safe_role = sanitize(&role.name);
out.push_str(&format!(
" role_{safe} [label=\"{}\"];\n identity -> role_{safe};\n",
role.name,
safe = safe_role
));
for perm in &role.permissions {
let safe_perm = sanitize(perm);
out.push_str(&format!(
" perm_{safe} [label=\"{}\"];\n role_{role_safe} -> perm_{safe};\n",
perm,
role_safe = safe_role,
safe = safe_perm
));
}
}
for res in &result.resources {
let safe = sanitize(&res.name);
out.push_str(&format!(
" res_{safe} [label=\"{} ({})\"];\n identity -> res_{safe};\n",
res.name,
res.risk,
safe = safe
));
}
out.push_str("}\n");
out
}
fn sanitize(name: &str) -> String {
name.chars().map(|c| if c.is_alphanumeric() { c } else { '_' }).collect()
}

View file

@ -401,6 +401,7 @@ fn build_html(json_str: &str, compressed_json_b64: &str) -> String {
const CLOUD_LOGOS = {
aws: '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path fill="currentColor" d="M6.763 10.036c0 .296.032.535.088.71.064.176.144.368.256.576.04.063.056.127.056.183 0 .08-.048.16-.152.24l-.503.335a.383.383 0 0 1-.208.072c-.08 0-.16-.04-.239-.112a2.47 2.47 0 0 1-.287-.375 6.18 6.18 0 0 1-.248-.471c-.622.734-1.405 1.101-2.347 1.101-.67 0-1.205-.191-1.596-.574-.391-.384-.59-.894-.59-1.533 0-.678.239-1.23.726-1.644.487-.415 1.133-.623 1.955-.623.272 0 .551.024.846.064.296.04.6.104.918.176v-.583c0-.607-.127-1.03-.375-1.277-.255-.248-.686-.367-1.3-.367-.28 0-.568.031-.863.103-.295.072-.583.16-.862.272a2.287 2.287 0 0 1-.28.104.488.488 0 0 1-.127.023c-.112 0-.168-.08-.168-.247v-.391c0-.128.016-.224.056-.28a.597.597 0 0 1 .224-.167c.279-.144.614-.264 1.005-.36a4.84 4.84 0 0 1 1.246-.151c.95 0 1.644.216 2.091.647.439.43.662 1.085.662 1.963v2.586zm-3.24 1.214c.263 0 .534-.048.822-.144.287-.096.543-.271.758-.51.128-.152.224-.32.272-.512.047-.191.08-.423.08-.694v-.335a6.66 6.66 0 0 0-.735-.136 6.02 6.02 0 0 0-.75-.048c-.535 0-.926.104-1.19.32-.263.215-.39.518-.39.917 0 .375.095.655.295.846.191.2.47.296.838.296zm6.41.862c-.144 0-.24-.024-.304-.08-.064-.048-.12-.16-.168-.311L7.586 5.55a1.398 1.398 0 0 1-.072-.32c0-.128.064-.2.191-.2h.783c.151 0 .255.025.31.08.065.048.113.16.16.312l1.342 5.284 1.245-5.284c.04-.16.088-.264.151-.312a.549.549 0 0 1 .32-.08h.638c.152 0 .256.025.32.08.063.048.12.16.151.312l1.261 5.348 1.381-5.348c.048-.16.104-.264.16-.312a.52.52 0 0 1 .311-.08h.743c.127 0 .2.065.2.2 0 .04-.009.08-.017.128a1.137 1.137 0 0 1-.056.2l-1.923 6.17c-.048.16-.104.263-.168.311a.51.51 0 0 1-.303.08h-.687c-.151 0-.255-.024-.32-.08-.063-.056-.119-.16-.15-.32l-1.238-5.148-1.23 5.14c-.04.16-.087.264-.15.32-.065.056-.177.08-.32.08zm10.256.215c-.415 0-.83-.048-1.229-.143-.399-.096-.71-.2-.918-.32-.128-.071-.215-.151-.247-.223a.563.563 0 0 1-.048-.224v-.407c0-.167.064-.247.183-.247.048 0 .096.008.144.024.048.016.12.048.2.08.271.12.566.215.878.279.319.064.63.096.95.096.502 0 .894-.088 1.165-.264a.86.86 0 0 0 .415-.758.777.777 0 0 0-.215-.559c-.144-.151-.416-.287-.807-.415l-1.157-.36c-.583-.183-1.014-.454-1.277-.813a1.902 1.902 0 0 1-.4-1.158c0-.335.073-.63.216-.886.144-.255.335-.479.575-.654.24-.184.51-.32.83-.415.32-.096.655-.136 1.006-.136.175 0 .359.008.535.032.183.024.35.056.518.088.16.04.312.08.455.127.144.048.256.096.336.144a.69.69 0 0 1 .24.2.43.43 0 0 1 .071.263v.375c0 .168-.064.256-.184.256a.83.83 0 0 1-.303-.096 3.652 3.652 0 0 0-1.532-.311c-.455 0-.815.071-1.062.223-.248.152-.375.383-.375.71 0 .224.08.416.24.567.159.152.454.304.877.44l1.134.358c.574.184.99.44 1.237.767.247.327.367.702.367 1.117 0 .343-.072.655-.207.926-.144.272-.336.511-.583.703-.248.2-.543.343-.886.447-.36.111-.734.167-1.142.167zM21.698 16.207c-2.626 1.94-6.442 2.969-9.722 2.969-4.598 0-8.74-1.7-11.87-4.526-.247-.223-.024-.527.272-.351 3.384 1.963 7.559 3.153 11.877 3.153 2.914 0 6.114-.607 9.06-1.852.439-.2.814.287.383.607zM22.792 14.961c-.336-.43-2.22-.207-3.074-.103-.255.032-.295-.192-.063-.36 1.5-1.053 3.967-.75 4.254-.399.287.36-.08 2.826-1.485 4.007-.215.184-.423.088-.327-.151.32-.79 1.03-2.57.695-2.994z"/></svg>',
gcp: '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path fill="currentColor" d="M12.19 2.38a9.344 9.344 0 0 0-9.234 6.893c.053-.02-.055.013 0 0-3.875 2.551-3.922 8.11-.247 10.941l.006-.007-.007.03a6.717 6.717 0 0 0 4.077 1.356h5.173l.03.03h5.192c6.687.053 9.376-8.605 3.835-12.35a9.365 9.365 0 0 0-2.821-4.552l-.043.043.006-.05A9.344 9.344 0 0 0 12.19 2.38zm-.358 4.146c1.244-.04 2.518.368 3.486 1.15a5.186 5.186 0 0 1 1.862 4.078v.518c3.53-.07 3.53 5.262 0 5.193h-5.193l-.008.009v-.04H6.785a2.59 2.59 0 0 1-1.067-.23h.001a2.597 2.597 0 1 1 3.437-3.437l3.013-3.012A6.747 6.747 0 0 0 8.11 8.24c.018-.01.04-.026.054-.023a5.186 5.186 0 0 1 3.67-1.69z"/></svg>',
slack: '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path fill="currentColor" d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52h-2.521zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.522 2.521 2.527 2.527 0 0 1-2.522-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.522 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.522 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.522-2.522v-2.522h2.522zM15.165 17.688a2.527 2.527 0 0 1-2.522-2.521 2.527 2.527 0 0 1 2.522-2.522h6.313A2.527 2.527 0 0 1 24 15.167a2.528 2.528 0 0 1-2.522 2.521h-6.313z"/></svg>',
unknown: '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.18"/><path fill="currentColor" d="M12 6c1.657 0 3 1.343 3 3 0 1.104-.672 2.052-1.624 2.674C12.518 12.318 12 13.095 12 14v.5a1 1 0 0 1-2 0V14c0-1.61.978-2.645 1.835-3.215C12.574 10.328 13 9.688 13 9c0-.552-.448-1-1-1s-1 .448-1 1a1 1 0 1 1-2 0c0-1.657 1.343-3 3-3zm0 11a1.25 1.25 0 1 1 0 2.5A1.25 1.25 0 0 1 12 17z"/></svg>'
};
@ -466,6 +467,7 @@ fn build_html(json_str: &str, compressed_json_b64: &str) -> String {
['Project', model.identity?.project || '—'],
['Tenant', model.identity?.tenant || '—'],
['Account', model.identity?.account_id || '—'],
['Fingerprint', model.fingerprint || '—'],
];
fields.forEach(([label, value]) => {
const item = document.createElement('div');
@ -720,6 +722,7 @@ fn build_html(json_str: &str, compressed_json_b64: &str) -> String {
return {
name: model.identity?.id || 'Identity',
type: 'identity',
fingerprint: model.fingerprint,
children: [
{ name: 'Resources', type: 'section', children: resourceNodes },
{ name: 'Roles', type: 'section', children: roleNodes },
@ -734,7 +737,8 @@ fn build_html(json_str: &str, compressed_json_b64: &str) -> String {
if (!node) return null;
const name = (node.name || '').toLowerCase();
const type = (node.type || '').toLowerCase();
const matchesSelf = query ? name.includes(query) || type.includes(query) : true;
const fp = (node.fingerprint || '').toLowerCase();
const matchesSelf = query ? name.includes(query) || type.includes(query) || fp.includes(query) : true;
if (!node.children || node.children.length === 0) {
return matchesSelf ? { ...node } : null;
}
@ -913,6 +917,43 @@ fn build_html(json_str: &str, compressed_json_b64: &str) -> String {
sev.textContent = `Severity: ${model.severity || 'unknown'}`;
meta.appendChild(sev);
if (model.fingerprint) {
const fp = document.createElement('div');
fp.style.width = '100%';
fp.style.marginTop = '4px';
fp.style.fontSize = '11px';
fp.style.color = '#9db4a8';
fp.textContent = `Fingerprint: ${model.fingerprint}`;
meta.appendChild(fp);
const btn = document.createElement('button');
btn.className = 'badge';
btn.style.marginTop = '6px';
btn.style.cursor = 'pointer';
btn.style.width = '100%';
btn.style.justifyContent = 'center';
btn.textContent = 'Go to finding';
btn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
const searchInput = document.getElementById('search-input');
if (searchInput) {
if (typeof setActiveView === 'function') setActiveView('view-findings');
searchInput.value = model.fingerprint;
currentFilter = model.fingerprint;
currentPage = 1;
renderFindingsTable();
setTimeout(() => {
const tableContainer = document.querySelector('.table-container');
if (tableContainer) {
tableContainer.scrollIntoView({behavior: 'smooth', block: 'start'});
}
}, 50);
}
};
meta.appendChild(btn);
}
li.appendChild(meta);
list.appendChild(li);
});

149
src/access_map/slack.rs Normal file
View file

@ -0,0 +1,149 @@
use anyhow::{anyhow, Result};
use reqwest::header::AUTHORIZATION;
use serde::Deserialize;
use super::{
build_recommendations, AccessMapArgs, AccessMapResult, AccessSummary, AccessTokenDetails,
PermissionSummary, ProviderMetadata, ResourceExposure, RoleBinding, Severity,
};
pub async fn map_access(args: &AccessMapArgs) -> Result<AccessMapResult> {
// For CLI usage, we might expect a token via env var or file, strictly speaking
// the CLI usually takes a file path for credentials.
// For Slack, it's just a token string.
// We'll assume the file contains the token, or if it's not a file, maybe it's the token itself?
// But consistency with other providers suggests reading from file.
let path = args
.credential_path
.as_deref()
.ok_or_else(|| anyhow!("Slack access-map requires a file path containing the token"))?;
let token = std::fs::read_to_string(path)?.trim().to_string();
map_access_from_token(&token).await
}
pub async fn map_access_from_token(token: &str) -> Result<AccessMapResult> {
let client = reqwest::Client::new();
let resp = client
.post("https://slack.com/api/auth.test")
.header(AUTHORIZATION, format!("Bearer {token}"))
.send()
.await?;
let headers = resp.headers().clone();
let scopes_header =
headers.get("x-oauth-scopes").and_then(|v| v.to_str().ok()).unwrap_or_default().to_string();
let body = resp.bytes().await?;
let json: AuthTestResponse = serde_json::from_slice(&body)?;
if !json.ok {
return Err(anyhow!("Slack auth.test failed: {}", json.error.unwrap_or_default()));
}
let user_id = json.user_id.unwrap_or_default();
let team_id = json.team_id.unwrap_or_default();
let team = json.team.unwrap_or_default();
let user = json.user.unwrap_or_default();
let url = json.url.unwrap_or_default();
let scopes: Vec<String> =
scopes_header.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
let identity = AccessSummary {
id: format!("{}@{}", user, team),
access_type: "user".into(), // Could be bot, but auth.test doesn't strictly say. xoxb is bot.
project: Some(team.clone()),
tenant: Some(team_id.clone()),
account_id: Some(user_id.clone()),
};
let mut roles = Vec::new();
// Treat scopes as permissions in a "Scopes" role
let mut expanded_permissions = Vec::new();
if !scopes.is_empty() {
roles.push(RoleBinding {
name: "OAuth Scopes".into(),
source: "token".into(),
permissions: scopes.clone(),
});
expanded_permissions.extend(scopes.clone());
}
let permissions = classify_permissions(&scopes);
let severity = derive_severity(&permissions);
let mut resources = Vec::new();
resources.push(ResourceExposure {
resource_type: "workspace".into(),
name: team,
permissions: scopes.clone(),
risk: "medium".into(),
reason: "Token has access to this workspace".into(),
});
let recommendations = build_recommendations(severity);
let token_details = AccessTokenDetails {
name: Some(user.clone()),
username: Some(user),
user_id: Some(user_id),
url: Some(url),
scopes,
..Default::default()
};
Ok(AccessMapResult {
cloud: "slack".into(),
identity,
roles,
permissions,
resources,
severity,
recommendations,
risk_notes: Vec::new(),
token_details: Some(token_details),
provider_metadata: Some(ProviderMetadata { version: None, enterprise: None }),
fingerprint: None,
})
}
#[derive(Deserialize)]
struct AuthTestResponse {
ok: bool,
error: Option<String>,
url: Option<String>,
team: Option<String>,
user: Option<String>,
team_id: Option<String>,
user_id: Option<String>,
}
fn classify_permissions(scopes: &[String]) -> PermissionSummary {
let mut admin = Vec::new();
let privilege_escalation = Vec::new();
let mut risky = Vec::new();
let mut read_only = Vec::new();
for scope in scopes {
if scope.starts_with("admin") {
admin.push(scope.clone());
} else if scope.contains("write") || scope.contains("manage") || scope.contains("remove") {
risky.push(scope.clone());
} else {
read_only.push(scope.clone());
}
}
PermissionSummary { admin, privilege_escalation, risky, read_only }
}
fn derive_severity(permissions: &PermissionSummary) -> Severity {
if !permissions.admin.is_empty() {
Severity::Critical
} else if !permissions.risky.is_empty() {
Severity::High
} else {
Severity::Medium
}
}

View file

@ -35,4 +35,6 @@ pub enum AccessMapProvider {
Github,
/// GitLab
Gitlab,
/// Slack
Slack,
}

View file

@ -18,6 +18,7 @@ use tokio::net::TcpListener;
use tracing::{info, warn};
pub const DEFAULT_PORT: u16 = 7890;
// Embedded viewer assets - force rebuild
static VIEWER_ASSETS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/docs/access-map-viewer");
/// View a Kingfisher access-map report locally.

View file

@ -659,6 +659,7 @@ impl DetailsReporter {
groups,
token_details: result.token_details.clone(),
provider_metadata: result.provider_metadata.clone(),
fingerprint: result.fingerprint.clone(),
});
}
@ -820,6 +821,8 @@ pub struct AccessMapEntry {
pub token_details: Option<AccessTokenDetails>,
#[serde(default)]
pub provider_metadata: Option<ProviderMetadata>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fingerprint: Option<String>,
}
#[derive(Serialize, JsonSchema, Clone, Debug)]

View file

@ -459,6 +459,7 @@ pub async fn run_async_scan(
let ran_repo_scan = Arc::new(AtomicBool::new(false));
let repo_errors: Arc<Mutex<Vec<anyhow::Error>>> = Arc::new(Mutex::new(Vec::new()));
let output_to_file = args.output_args.output.is_some();
rayon::ThreadPoolBuilder::new()
.num_threads(repo_concurrency)
@ -538,8 +539,10 @@ pub async fn run_async_scan(
global_stats.update(&repo_matcher_stats.lock().unwrap());
}
crate::reporter::run(global_args, Arc::clone(&repo_datastore), &args)
.context("Failed to run report command")?;
if !output_to_file {
crate::reporter::run(global_args, Arc::clone(&repo_datastore), &args)
.context("Failed to run report command")?;
}
{
let mut ds = datastore.lock().unwrap();
@ -574,6 +577,11 @@ pub async fn run_async_scan(
return Err(err);
}
if output_to_file && ran_repo_scan.load(Ordering::Relaxed) {
crate::reporter::run(global_args, Arc::clone(&datastore), args)
.context("Failed to run report command")?;
}
if !ran_repo_scan.load(Ordering::Relaxed) {
deduplicate_new_matches(&datastore, 0)?;

View file

@ -35,51 +35,67 @@ pub struct AccessMapCollector {
}
impl AccessMapCollector {
pub fn record_aws(&self, access_key: &str, secret_key: &str) {
pub fn record_aws(&self, access_key: &str, secret_key: &str, fingerprint: String) {
let key = xxhash_rust::xxh3::xxh3_64(format!("aws|{access_key}|{secret_key}").as_bytes());
self.inner.entry(key).or_insert_with(|| AccessMapRequest::Aws {
access_key: access_key.to_string(),
secret_key: secret_key.to_string(),
session_token: None,
fingerprint,
});
}
pub fn record_gcp(&self, credential_json: &str) {
pub fn record_gcp(&self, credential_json: &str, fingerprint: String) {
let key = xxhash_rust::xxh3::xxh3_64(credential_json.as_bytes());
self.inner.entry(key).or_insert_with(|| AccessMapRequest::Gcp {
credential_json: credential_json.to_string(),
fingerprint,
});
}
pub fn record_azure(&self, credential_json: &str, containers: Option<Vec<String>>) {
pub fn record_azure(
&self,
credential_json: &str,
containers: Option<Vec<String>>,
fingerprint: String,
) {
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,
fingerprint,
});
}
pub fn record_azure_devops(&self, token: &str, organization: &str) {
pub fn record_azure_devops(&self, token: &str, organization: &str, fingerprint: String) {
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(),
fingerprint,
});
}
pub fn record_github(&self, token: &str) {
pub fn record_github(&self, token: &str, fingerprint: String) {
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() });
.or_insert_with(|| AccessMapRequest::Github { token: token.to_string(), fingerprint });
}
pub fn record_gitlab(&self, token: &str) {
pub fn record_gitlab(&self, token: &str, fingerprint: String) {
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() });
.or_insert_with(|| AccessMapRequest::Gitlab { token: token.to_string(), fingerprint });
}
pub fn record_slack(&self, token: &str, fingerprint: String) {
let key = xxhash_rust::xxh3::xxh3_64(format!("slack|{token}").as_bytes());
self.inner
.entry(key)
.or_insert_with(|| AccessMapRequest::Slack { token: token.to_string(), fingerprint });
}
pub fn into_requests(self) -> Vec<AccessMapRequest> {
@ -556,6 +572,7 @@ fn maybe_record_access_map(om: &OwnedBlobMatch, collector: Option<&AccessMapColl
};
let captures = utils::process_captures(&om.captures);
let fp = om.finding_fingerprint.to_string();
match om.rule.syntax().validation {
Some(Validation::AWS) => {
@ -573,13 +590,13 @@ fn maybe_record_access_map(om: &OwnedBlobMatch, collector: Option<&AccessMapColl
}
if !akid.is_empty() && !secret.is_empty() {
collector.record_aws(&akid, &secret);
collector.record_aws(&akid, &secret, fp.clone());
}
}
Some(Validation::GCP) => {
if let Some((_, value, ..)) = captures.iter().find(|(name, ..)| name == "TOKEN") {
if !value.is_empty() {
collector.record_gcp(value);
collector.record_gcp(value, fp.clone());
}
}
}
@ -607,14 +624,14 @@ fn maybe_record_access_map(om: &OwnedBlobMatch, collector: Option<&AccessMapColl
r#"{{"storage_account":"{}","storage_key":"{}"}}"#,
storage_account, storage_key
);
collector.record_azure(&creds_json, containers_hint);
collector.record_azure(&creds_json, containers_hint, fp.clone());
}
}
_ => {
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);
collector.record_github(value, fp.clone());
}
}
}
@ -633,13 +650,20 @@ fn maybe_record_access_map(om: &OwnedBlobMatch, collector: Option<&AccessMapColl
}
if !token.is_empty() && !organization.is_empty() {
collector.record_azure_devops(&token, &organization);
collector.record_azure_devops(&token, &organization, fp.clone());
}
}
if is_gitlab_rule {
if let Some((_, value, ..)) = captures.iter().find(|(name, ..)| name == "TOKEN") {
if !value.is_empty() {
collector.record_gitlab(value);
collector.record_gitlab(value, fp.clone());
}
}
}
if om.rule.id().starts_with("kingfisher.slack.") {
if let Some((_, value, ..)) = captures.iter().find(|(name, ..)| name == "TOKEN") {
if !value.is_empty() {
collector.record_slack(value, fp);
}
}
}

View file

@ -600,6 +600,9 @@ mod tests {
// This should not panic AND should correctly identify HTML
let result = body_looks_like_html(&body, &headers);
assert!(result, "Should correctly identify HTML even with multi-byte characters at boundary");
assert!(
result,
"Should correctly identify HTML even with multi-byte characters at boundary"
);
}
}