diff --git a/crates/kingfisher-rules/data/rules/agora.yml b/crates/kingfisher-rules/data/rules/agora.yml index fbf7168..ab0a91c 100644 --- a/crates/kingfisher-rules/data/rules/agora.yml +++ b/crates/kingfisher-rules/data/rules/agora.yml @@ -6,7 +6,7 @@ rules: \b agora (?:.|[\n\r]){0,32}? - \b(?:app[_-]?id|customer[_-]?id)\b + (?:\b|_)(?:app[_-]?id|customer[_-]?id)\b (?:.|[\n\r]){0,16}? [=:"'\s] \b diff --git a/crates/kingfisher-rules/data/rules/configcat.yml b/crates/kingfisher-rules/data/rules/configcat.yml index 7feeac6..2ef1b0f 100644 --- a/crates/kingfisher-rules/data/rules/configcat.yml +++ b/crates/kingfisher-rules/data/rules/configcat.yml @@ -19,8 +19,8 @@ rules: min_entropy: 3.5 confidence: medium examples: - - 'CONFIGCAT_SDK_KEY=PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A' - - 'configcat_key: "PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A"' + - 'CONFIGCAT_SDK_KEY=Aa1Bb2Cc3Dd4Ee5Ff6Gg7H/aA1bB2cC3dD4eE5fF6gG7h' + - 'configcat_key: "Aa1Bb2Cc3Dd4Ee5Ff6Gg7H/aA1bB2cC3dD4eE5fF6gG7h"' references: - https://configcat.com/docs/sdk-reference/overview/ validation: @@ -51,7 +51,7 @@ rules: min_entropy: 3.5 confidence: medium examples: - - 'CONFIGCAT_SDK_KEY=configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A' + - 'CONFIGCAT_SDK_KEY=configcat-sdk-1/Aa1Bb2Cc3Dd4Ee5Ff6Gg7H/aA1bB2cC3dD4eE5fF6gG7h' references: - https://configcat.com/docs/sdk-reference/overview/ validation: diff --git a/crates/kingfisher-rules/data/rules/confluence.yml b/crates/kingfisher-rules/data/rules/confluence.yml new file mode 100644 index 0000000..8ba3815 --- /dev/null +++ b/crates/kingfisher-rules/data/rules/confluence.yml @@ -0,0 +1,69 @@ +rules: + - name: Confluence Data Center Personal Access Token + id: kingfisher.confluence.1 + pattern: | + (?x) + (?i:confluence|wiki) + (?:.|[\n\r]){0,16}? + \b + ( + [MNO][A-Za-z0-9+/]{15} + O[g-v] + [A-Za-z0-9+/]{26} + ) + pattern_requirements: + min_digits: 2 + min_uppercase: 1 + min_lowercase: 1 + min_entropy: 4.0 + confidence: medium + examples: + - 'confluence_pat: "MjQzMjkzMDQyNTI1OgTGWAoKFZTh/Is7cl+cdAI0Lbxo"' + - 'wiki_PAT=MDgxODgyOTYwNTA5OkFSuEyq1mtrLTVNGAPyka+/Vyfv' + references: + - https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html + - https://developer.atlassian.com/server/confluence/confluence-server-rest-api/ + validation: + type: Http + content: + request: + headers: + Accept: application/json + Authorization: Bearer {{ TOKEN }} + method: GET + response_matcher: + - report_response: true + - status: + - 200 + type: StatusMatch + - type: JsonValid + - type: WordMatch + words: + - '"type":"known"' + url: https://{{ CONFLUENCEDCDOMAIN }}/rest/api/user/current + depends_on_rule: + - rule_id: kingfisher.confluence.2 + variable: CONFLUENCEDCDOMAIN + + - name: Confluence Data Center Domain + id: kingfisher.confluence.2 + pattern: | + (?xi) + (?:https?://)? + \b + ( + (?:[a-z0-9-]+\.){0,16} + (?:wiki|confluence)[a-z0-9-]* + \.[a-z0-9.-]{2,64} + (?::\d{2,5})? + ) + \b + min_entropy: 2.5 + visible: false + confidence: medium + examples: + - wiki.corp.mongodb.com + - confluence.example.com + - https://wiki-staging.corp.internal:8443 + references: + - https://confluence.atlassian.com/doc/confluence-server-documentation-135922.html diff --git a/crates/kingfisher-rules/data/rules/docusign.yml b/crates/kingfisher-rules/data/rules/docusign.yml index 56cf8a1..45580b1 100644 --- a/crates/kingfisher-rules/data/rules/docusign.yml +++ b/crates/kingfisher-rules/data/rules/docusign.yml @@ -23,39 +23,14 @@ rules: min_entropy: 3.0 confidence: medium examples: - - "docusign.secret_key = 7a39ce6d-94cf-4bf6-9e9e-9213373c15f4" - - "docusign\nds_secret = 3d2f18c9-2075-4e78-834b-64f57f8757d0" - validation: - type: Http - content: - request: - method: POST - url: "https://{{ DOCUSIGN_AUTH_HOST }}/oauth/token" - headers: - Accept: application/json - Content-Type: application/x-www-form-urlencoded - body: > - grant_type=authorization_code&code=INVALID_AUTH_CODE&client_id={{ DOCUSIGN_CLIENT_ID | url_encode }}&client_secret={{ TOKEN | url_encode }}&redirect_uri={{ REDIRECT_URI | url_encode }} - response_matcher: - - report_response: true - - type: StatusMatch - status: [400] - - type: WordMatch - match_all_words: false - words: - - invalid_grant - - invalid authorization code - - type: WordMatch - words: - - invalid_client - negative: true - depends_on_rule: - - rule_id: kingfisher.docusign.2 - variable: DOCUSIGN_CLIENT_ID - - rule_id: kingfisher.docusign.3 - variable: DOCUSIGN_AUTH_HOST - - rule_id: kingfisher.docusign.4 - variable: REDIRECT_URI + - "docusign.secret_key = 12345678-abcd-9876-5432-abcdef123456" + - "docusign\nds_secret = 87654321-fedc-1234-abcd-fedcba987654" + # Validation intentionally omitted: DocuSign's /oauth/token endpoint + # returns {"error":"invalid_grant"} for any request with an invalid + # authorization code, regardless of whether client_id/client_secret are + # actually valid. That makes it impossible to distinguish live from + # inactive credentials via that endpoint without performing a full OAuth + # flow, which is out of scope for passive validation. references: - https://developers.docusign.com/platform/auth/ - https://developers.docusign.com/platform/build-integration/ diff --git a/crates/kingfisher-rules/data/rules/google.yml b/crates/kingfisher-rules/data/rules/google.yml index 4cfbde6..65cad21 100644 --- a/crates/kingfisher-rules/data/rules/google.yml +++ b/crates/kingfisher-rules/data/rules/google.yml @@ -22,7 +22,7 @@ rules: min_entropy: 3.3 confidence: medium examples: - - 'const CLIENTSECRET = "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"' + - 'const CLIENTSECRET = "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"' depends_on_rule: - rule_id: "kingfisher.google.1" variable: GOOGLE_CLIENT_ID @@ -41,9 +41,20 @@ rules: - report_response: true - type: StatusMatch status: [400] + - type: WordMatch + match_all_words: false + words: + - invalid_grant + - Malformed auth code + - Bad Request + # Only mark as active when Google acknowledges the credentials + # and rejects the (intentionally invalid) authorization code. - type: WordMatch words: - invalid_client + - unauthorized_client + - unsupported_grant_type + - invalid_request negative: true # Revocation not added: Google's OAuth revocation endpoint revokes tokens, # not client secrets. @@ -80,9 +91,20 @@ rules: - report_response: true - type: StatusMatch status: [400] + - type: WordMatch + match_all_words: false + words: + - invalid_grant + - Malformed auth code + - Bad Request + # Only mark as active when Google acknowledges the credentials + # and rejects the (intentionally invalid) authorization code. - type: WordMatch words: - invalid_client + - unauthorized_client + - unsupported_grant_type + - invalid_request negative: true # Revocation not added: Google's OAuth revocation endpoint revokes tokens, # not client secrets. diff --git a/crates/kingfisher-rules/data/rules/highnote.yml b/crates/kingfisher-rules/data/rules/highnote.yml index c7d3959..0bf5948 100644 --- a/crates/kingfisher-rules/data/rules/highnote.yml +++ b/crates/kingfisher-rules/data/rules/highnote.yml @@ -8,9 +8,9 @@ rules: (?:.|[\n\r]){0,24}? \b ( - sk_live_a2V5Xz[A-Za-z0-9+/]{69} + sk_live_a2V5Xz[A-Za-z0-9+/]{69}={0,2} ) - (?![A-Za-z0-9+/]) + (?:[^A-Za-z0-9+/=]|$) pattern_requirements: min_digits: 2 min_entropy: 3.5 diff --git a/crates/kingfisher-rules/data/rules/huawei.yml b/crates/kingfisher-rules/data/rules/huawei.yml index 693f593..5b33a3d 100644 --- a/crates/kingfisher-rules/data/rules/huawei.yml +++ b/crates/kingfisher-rules/data/rules/huawei.yml @@ -6,7 +6,7 @@ rules: \b huawei (?:.|[\n\r]){0,32}? - \b(?:client[_-]?id|app[_-]?id)\b + (?:\b|_)(?:client[_-]?id|app[_-]?id)\b (?:.|[\n\r]){0,16}? [=:"'\s] \b diff --git a/crates/kingfisher-rules/data/rules/jira.yml b/crates/kingfisher-rules/data/rules/jira.yml index e5dee8c..23ef153 100644 --- a/crates/kingfisher-rules/data/rules/jira.yml +++ b/crates/kingfisher-rules/data/rules/jira.yml @@ -58,4 +58,98 @@ rules: - https://developer.atlassian.com/cloud/jira/platform/basic-auth-for-rest-apis/ depends_on_rule: - rule_id: kingfisher.jira.1 - variable: DOMAIN \ No newline at end of file + variable: DOMAIN + - name: Jira Data Center Personal Access Token + id: kingfisher.jira.3 + pattern: | + (?x) + (?i:jira|atlassian) + (?:.|[\n\r]){0,16}? + \b + ( + [MNO][A-Za-z0-9+/]{15} + O[g-v] + [A-Za-z0-9+/]{26} + ) + pattern_requirements: + min_digits: 2 + min_uppercase: 1 + min_lowercase: 1 + min_entropy: 4.0 + confidence: medium + examples: + - 'jira_token: "Mjc2NTIyMTkxNTY2OkurZAe0a40+xLE2fJRBcq/P2vsL"' + - 'atlassian_PAT=OTI0NTIyOQkzMTk3OgyypbjdwdDzTavLf2R1Ls0XJAPm' + references: + - https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html + - https://developer.atlassian.com/server/jira/platform/personal-access-token/ + validation: + type: Http + content: + request: + headers: + Accept: application/json + Authorization: Bearer {{ TOKEN }} + method: GET + response_matcher: + - report_response: true + - status: + - 200 + type: StatusMatch + - type: JsonValid + url: https://{{ JIRADCDOMAIN }}/rest/api/latest/myself + revocation: + type: HttpMultiStep + content: + steps: + - name: lookup_token_id + request: + method: GET + url: https://{{ JIRADCDOMAIN }}/rest/pat/latest/tokens + headers: + Accept: application/json + Authorization: Bearer {{ TOKEN }} + response_matcher: + - type: StatusMatch + status: [200] + - type: JsonValid + extract: + JIRA_TOKEN_ID: + type: JsonPath + path: "$[0].id" + - name: revoke_token + request: + method: DELETE + url: https://{{ JIRADCDOMAIN }}/rest/pat/latest/tokens/{{ JIRA_TOKEN_ID }} + headers: + Authorization: Bearer {{ TOKEN }} + response_matcher: + - report_response: true + - type: StatusMatch + status: [204] + depends_on_rule: + - rule_id: kingfisher.jira.4 + variable: JIRADCDOMAIN + + - name: Jira Data Center Domain + id: kingfisher.jira.4 + pattern: | + (?xi) + (?:https?://)? + \b + ( + (?:[a-z0-9-]+\.){0,16} + jira[a-z0-9-]* + \.[a-z0-9.-]{2,64} + (?::\d{2,5})? + ) + \b + min_entropy: 2.5 + visible: false + confidence: medium + examples: + - jira.example.com + - jira-staging.corp.mongodb.com + - https://jira.corp.internal:8443 + references: + - https://confluence.atlassian.com/adminjiraserver/jira-applications-base-url-938846869.html \ No newline at end of file diff --git a/crates/kingfisher-rules/data/rules/mixpanel.yml b/crates/kingfisher-rules/data/rules/mixpanel.yml index 56d371b..2ad880b 100644 --- a/crates/kingfisher-rules/data/rules/mixpanel.yml +++ b/crates/kingfisher-rules/data/rules/mixpanel.yml @@ -60,7 +60,7 @@ rules: min_entropy: 3.5 confidence: medium examples: - - "kingfisher-svc-1.b500ae.mp-service-account\nSecret: Vqprs8MMJm3XSpfWiuAFECFuyKxxrJL1" # nosemgrep + - "example-svc-1.abcdef.mp-service-account\nSecret: FakeExampleServiceAcctSecretAb12" # nosemgrep negative_examples: - mp-service-account - "mp-service-account\nsecret: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" # nosemgrep diff --git a/crates/kingfisher-rules/data/rules/pangea.yml b/crates/kingfisher-rules/data/rules/pangea.yml index 0300170..ca60962 100644 --- a/crates/kingfisher-rules/data/rules/pangea.yml +++ b/crates/kingfisher-rules/data/rules/pangea.yml @@ -28,7 +28,12 @@ rules: body: '{"event":{"message":"test"}}' response_matcher: - report_response: true - - type: StatusMatch - status: [200, 401, 403] + - type: JsonValid + - type: WordMatch + words: + - '"status":"Unauthorized"' + - '"status":"NotAuthorized"' + - '"status":"Forbidden"' + negative: true references: - https://pangea.cloud/docs/ diff --git a/crates/kingfisher-rules/data/rules/storyblok.yml b/crates/kingfisher-rules/data/rules/storyblok.yml index 833fe81..c4bd015 100644 --- a/crates/kingfisher-rules/data/rules/storyblok.yml +++ b/crates/kingfisher-rules/data/rules/storyblok.yml @@ -18,8 +18,8 @@ rules: pattern_requirements: min_digits: 2 examples: - - STORYBLOK_ACCESS_TOKEN=wANpEQEsMYGOwLxwXQ76Ggtt - - storyblok_token = "13Kft3335iwbBOI333wawOtt" + - STORYBLOK_ACCESS_TOKEN=ExampleBogusTokenXYZ12tt + - storyblok_token = "FakeExampleTok45AB67CDtt" references: - https://www.storyblok.com/docs/api/content-delivery/v2/getting-started/authentication - https://www.storyblok.com/docs/concepts/access-tokens diff --git a/crates/kingfisher-rules/data/rules/webex.yml b/crates/kingfisher-rules/data/rules/webex.yml index f290df0..f4a7923 100644 --- a/crates/kingfisher-rules/data/rules/webex.yml +++ b/crates/kingfisher-rules/data/rules/webex.yml @@ -6,7 +6,7 @@ rules: \b webex (?:.|[\n\r]){0,32}? - \b(?:client[_-]?id|client)\b + (?:\b|_)(?:client[_-]?id|client)\b (?:.|[\n\r]){0,16}? [=:"'\s] \b @@ -20,7 +20,7 @@ rules: confidence: medium visible: false examples: - - "webex_client = Ac0769801df88a3535b4b018ef570b499002bda401b3b8789259a937f22d66095" + - "webex_client = c0769801df88a3535b4b018ef570b499002bda401b3b8789259a937f22d66095" - "WEBEX_CLIENT_ID=1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b" references: - https://developer.webex.com/docs/platform-introduction diff --git a/crates/kingfisher-rules/data/rules/workos.yml b/crates/kingfisher-rules/data/rules/workos.yml index addeb77..955ee1a 100644 --- a/crates/kingfisher-rules/data/rules/workos.yml +++ b/crates/kingfisher-rules/data/rules/workos.yml @@ -8,9 +8,9 @@ rules: (?:.|[\n\r]){0,24}? \b ( - sk_live_a2V5Xz[A-Za-z0-9+/]{69} + sk_live_a2V5Xz[A-Za-z0-9+/]{69}={0,2} ) - (?![A-Za-z0-9+/]) + (?:[^A-Za-z0-9+/=]|$) pattern_requirements: min_digits: 4 min_lowercase: 4 diff --git a/docs/viewer/index.html b/docs/viewer/index.html index cc1577b..a34d79b 100644 --- a/docs/viewer/index.html +++ b/docs/viewer/index.html @@ -3011,7 +3011,56 @@ return payload ? [payload] : []; } - function buildGitMetadata(commitId, email, fileUrl) { + function normalizeRepositoryWebUrl(repoUrl) { + const raw = String(repoUrl || "").trim(); + if (!raw || /^file:\/\//i.test(raw)) return ""; + if (/^https?:\/\//i.test(raw)) return raw.replace(/\.git$/i, "").replace(/\/+$/g, ""); + + const sshMatch = raw.match(/^git@([^:]+):(.+)$/i); + if (sshMatch) { + return `https://${sshMatch[1]}/${sshMatch[2].replace(/\.git$/i, "").replace(/^\/+/, "")}`.replace(/\/+$/g, ""); + } + + const sshUrlMatch = raw.match(/^ssh:\/\/git@([^/]+)\/(.+)$/i); + if (sshUrlMatch) { + return `https://${sshUrlMatch[1]}/${sshUrlMatch[2].replace(/\.git$/i, "").replace(/^\/+/, "")}`.replace(/\/+$/g, ""); + } + + return ""; + } + + function buildGitFileUrl(repoUrl, commitId, filePath, line) { + const base = normalizeRepositoryWebUrl(repoUrl); + const commit = String(commitId || "").trim(); + const normalizedPath = String(filePath || "").replace(/\\/g, "/").replace(/^\/+/, ""); + const lineNumber = toNumberOrNull(line); + if (!base || !commit || !normalizedPath) return ""; + + try { + const parsed = new URL(base); + const host = (parsed.hostname || "").toLowerCase(); + if (host === "dev.azure.com" || host.endsWith(".visualstudio.com")) { + const encodedPath = encodeURIComponent(normalizedPath); + return lineNumber + ? `${base}/commit/${commit}?path=/${encodedPath}&line=${lineNumber}` + : `${base}/commit/${commit}?path=/${encodedPath}`; + } + if (host === "bitbucket.org") { + const anchor = encodeURIComponent(normalizedPath).replace(/%2F/g, "/"); + return lineNumber + ? `${base}/commits/${commit}#L${anchor}F${lineNumber}` + : `${base}/commits/${commit}`; + } + } catch (_) { + return ""; + } + + return lineNumber + ? `${base}/blob/${commit}/${normalizedPath}#L${lineNumber}` + : `${base}/blob/${commit}/${normalizedPath}`; + } + + function buildGitMetadata(commitId, email, fileUrl, repositoryUrl) { const gitMetadata = {}; if (commitId || email) { gitMetadata.commit = {}; @@ -3021,6 +3070,9 @@ if (fileUrl) { gitMetadata.file = { url: String(fileUrl) }; } + if (repositoryUrl) { + gitMetadata.repository = { url: String(repositoryUrl) }; + } return Object.keys(gitMetadata).length ? gitMetadata : null; } @@ -3059,10 +3111,16 @@ ? buildSyntheticFingerprint("gitleaks", [ruleId, secretIdentity]) : buildSyntheticFingerprint("gitleaks", [ruleId, path, line, columnStart, snippet])) ); + const repoUrl = firstNonEmpty(item.RepoURL, item.repoURL, item.Repo, item.repo); + const directUrl = firstNonEmpty(item.LeakURL, item.leakURL, item.Url, item.url); + const fileUrl = isHttpUrl(directUrl) + ? directUrl + : buildGitFileUrl(repoUrl, firstNonEmpty(item.Commit, item.commit), path, line); const gitMetadata = buildGitMetadata( firstNonEmpty(item.Commit, item.commit), firstNonEmpty(item.Email, item.email), - firstNonEmpty(item.LeakURL, item.leakURL, item.Url, item.url) + fileUrl, + normalizeRepositoryWebUrl(repoUrl) ); return { @@ -3099,6 +3157,7 @@ const detectorName = String(firstNonEmpty(item.DetectorName, item.detectorName, item.DetectorDescription, item.detectorDescription) || "Unknown Detector"); const detectorDescription = String(firstNonEmpty(item.DetectorDescription, item.detectorDescription, detectorName) || detectorName); const sourceMetadata = item.SourceMetadata || item.sourceMetadata || {}; + const gitSource = findDeepValue(sourceMetadata, ["git"]) || {}; const path = String(firstNonEmpty( findDeepValue(sourceMetadata, ["file", "path", "filename"]), item.File, @@ -3114,6 +3173,12 @@ item.Url, item.url ); + const repositoryUrl = firstNonEmpty( + findDeepValue(gitSource, ["repository", "repositoryurl", "repo", "repo_url", "remote", "remoteurl", "remote_url"]), + findDeepValue(sourceMetadata, ["repository", "repositoryurl", "repo", "repo_url", "remote", "remoteurl", "remote_url"]), + item.Repository, + item.repository + ); const commitId = firstNonEmpty( findDeepValue(sourceMetadata, ["commit", "commitid", "hash", "sha"]), item.Commit, @@ -3134,7 +3199,9 @@ ); const verified = Boolean(item.Verified); const verificationError = String(firstNonEmpty(item.VerificationError, item.verificationError) || ""); - const gitMetadata = buildGitMetadata(commitId, email, isHttpUrl(sourceUrl) ? sourceUrl : ""); + const normalizedRepoUrl = normalizeRepositoryWebUrl(repositoryUrl); + const fileUrl = isHttpUrl(sourceUrl) ? sourceUrl : buildGitFileUrl(normalizedRepoUrl, commitId, path, line); + const gitMetadata = buildGitMetadata(commitId, email, fileUrl, normalizedRepoUrl); return { rule: { @@ -3639,6 +3706,19 @@ revoke_command: kf.revoke_command || "", confidence: kf.confidence || "", }; + if (kf.git_metadata && (!fg.git_metadata || !fg.git_metadata.file || !fg.git_metadata.file.url)) { + fg.git_metadata = Object.assign({}, fg.git_metadata || {}); + if (kf.git_metadata.file && kf.git_metadata.file.url) { + fg.git_metadata.file = Object.assign({}, fg.git_metadata.file || {}, { + url: kf.git_metadata.file.url, + }); + } + if (kf.git_metadata.repository && kf.git_metadata.repository.url) { + fg.git_metadata.repository = Object.assign({}, fg.git_metadata.repository || {}, { + url: kf.git_metadata.repository.url, + }); + } + } } return findingList; } @@ -4547,6 +4627,7 @@ const headers = [ "rule_id", "rule_name", + "source_tool", "file_path", "line", "validation_status", @@ -4563,6 +4644,7 @@ return [ rule.id || "", rule.name || "", + getFindingSourceDisplayName(finding), finding.path || "", finding.line != null ? finding.line : "", status, @@ -4736,10 +4818,12 @@ const normalizedStatus = normalizeValidationStatus(statusRaw); const findingId = getFindingIdFromFinding(finding); const gitUrl = getFileUrlFromFinding(finding); + const sourceTool = getFindingSourceDisplayName(finding); const statusColor = normalizedStatus === "active" ? "#dc2626" : normalizedStatus === "inactive" ? "#f97316" : "#6b7280"; return `