forked from mirrors/kingfisher
commit
1be10ee8c9
32 changed files with 918 additions and 223 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 Intel’s 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
52
data/rules/apollo.yml
Normal 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"'
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
25
data/rules/clay.yml
Normal 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
39
data/rules/coderabbit.yml
Normal 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
68
data/rules/customerio.yml
Normal 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"'
|
||||
|
|
@ -42,6 +42,5 @@ rules:
|
|||
- report_response: true
|
||||
- type: StatusMatch
|
||||
status: [200]
|
||||
- type: JsonValid
|
||||
- type: WordMatch
|
||||
words: ['"answer"']
|
||||
|
|
|
|||
|
|
@ -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
41
data/rules/instantly.yml
Normal 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
|
||||
|
|
@ -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
44
data/rules/vastai.yml
Normal 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"'
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@ async fn map_access_with_config(config: SdkConfig) -> Result<AccessMapResult> {
|
|||
scopes: Vec::new(),
|
||||
}),
|
||||
provider_metadata: None,
|
||||
fingerprint: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ pub async fn map_access_from_json_with_hints(
|
|||
risk_notes,
|
||||
token_details: None,
|
||||
provider_metadata: None,
|
||||
fingerprint: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -282,6 +282,7 @@ pub async fn map_access_from_token(token: &str) -> Result<AccessMapResult> {
|
|||
scopes: oauth_scopes.clone(),
|
||||
}),
|
||||
provider_metadata: None,
|
||||
fingerprint: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
149
src/access_map/slack.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -35,4 +35,6 @@ pub enum AccessMapProvider {
|
|||
Github,
|
||||
/// GitLab
|
||||
Gitlab,
|
||||
/// Slack
|
||||
Slack,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue