|
|
@ -2,6 +2,11 @@
|
|||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.55.0]
|
||||
- Added first-class Azure Repos support, including CLI commands, enumeration, and documentation updates
|
||||
- Improved performance of tree-sitter parsing
|
||||
- Updated Windows build script to ensure static binary is produced
|
||||
|
||||
## [v1.54.0]
|
||||
- Added first-class Gitea support, including CLI commands, environment-based authentication, documentation, and integration with scans and repository enumeration.
|
||||
- Populate the finding path from git blob metadata so history-derived secrets display their file location instead of an empty path
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ publish = false
|
|||
|
||||
[package]
|
||||
name = "kingfisher"
|
||||
version = "1.54.0"
|
||||
description = "MongoDB's blazingly fast secret scanning and validation tool"
|
||||
version = "1.55.0"
|
||||
description = "MongoDB's blazingly fast and accurate secret scanning and validation tool"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
|
@ -32,7 +32,7 @@ assets = [
|
|||
|
||||
[package.metadata.generate-rpm]
|
||||
package = "kingfisher"
|
||||
summary = "MongoDB's blazingly fast secret scanning and validation tool"
|
||||
summary = "MongoDB's blazingly fast and accurate secret scanning and validation tool"
|
||||
license = "Apache-2.0"
|
||||
url = "https://github.com/mongodb/kingfisher"
|
||||
assets = [
|
||||
|
|
@ -229,7 +229,7 @@ incremental = false
|
|||
|
||||
[profile.dev]
|
||||
opt-level = 0
|
||||
debug = true
|
||||
# debug = true
|
||||
incremental = true
|
||||
codegen-units = 256
|
||||
|
||||
|
|
|
|||
139
README.md
|
|
@ -5,29 +5,26 @@
|
|||
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
|
||||
Kingfisher is a blazingly fast secret‑scanning and live validation tool built in Rust. It combines Intel’s hardware‑accelerated Hyperscan regex engine with language‑aware parsing via Tree‑Sitter, and **ships with hundreds of built‑in rules** to detect, validate, and triage secrets before they ever reach production
|
||||
Kingfisher is a blazingly fast secret‑scanning and live validation tool built in Rust. It combines Intel’s hardware‑accelerated Hyperscan regex engine with language‑aware source code parsing, and **ships with hundreds of built‑in rules** to detect, validate, and triage secrets before they ever reach production
|
||||
</p>
|
||||
|
||||
Originally forked from Praetorian’s Nosey Parker, Kingfisher **adds** live cloud-API validation; many more targets (GitLab, BitBucket, Gitea, S3, Docker, Jira, Confluence, Slack); compressed-file extraction and scanning; baseline and allowlist controls; language-aware detection (~20 languages); and a native Windows binary. See [Origins and Divergence](#origins-and-divergence) for details.
|
||||
|
||||
## Key Features
|
||||
- **Multiple Scan Targets**:
|
||||
<p align="center">
|
||||
<img alt="Files & Dirs" src="https://img.shields.io/badge/Files%20and%20Dirs-000?logoColor=white" />
|
||||
<img alt="Local Git" src="https://img.shields.io/badge/Local%20Git%20Repos-000?logo=git&logoColor=white" />
|
||||
<img alt="GitHub" src="https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white" />
|
||||
<img alt="GitLab" src="https://img.shields.io/badge/GitLab-FC6D26?logo=gitlab&logoColor=white" />
|
||||
<img alt="Bitbucket" src="https://img.shields.io/badge/Bitbucket-0052CC?logo=bitbucket&logoColor=white" />
|
||||
<img alt="Gitea" src="https://img.shields.io/badge/Gitea-609926?logo=gitea&logoColor=white" />
|
||||
<br/>
|
||||
<img alt="Docker" src="https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white" />
|
||||
<img alt="Jira" src="https://img.shields.io/badge/Jira-0052CC?logo=jirasoftware&logoColor=white" />
|
||||
<img alt="Confluence" src="https://img.shields.io/badge/Confluence-172B4D?logo=confluence&logoColor=white" />
|
||||
<img alt="Slack" src="https://img.shields.io/badge/Slack-4A154B?logo=slack&logoColor=white" />
|
||||
<img alt="AWS S3" src="https://img.shields.io/badge/AWS%20S3-232F3E?logo=amazonaws&logoColor=white" />
|
||||
</p>
|
||||
|
||||
### Multiple Scan Targets
|
||||
<div align="center">
|
||||
|
||||
| Files / Dirs | Local Git | GitHub | GitLab | Azure DevOps | Bitbucket | Gitea |
|
||||
|:-------------:|:----------:|:------:|:------:|:-------------:|:----------:|:------:|
|
||||
| <img src="./docs/assets/icons/files.svg" height="40" alt="Files / Dirs"/><br/><sub>Files / Dirs</sub> | <img src="./docs/assets/icons/local-git.svg" height="40" alt="Local Git"/><br/><sub>Local Git</sub> | <img src="./docs/assets/icons/github.svg" height="40" alt="GitHub"/><br/><sub>GitHub</sub> | <img src="./docs/assets/icons/gitlab.svg" height="40" alt="GitLab"/><br/><sub>GitLab</sub> | <img src="./docs/assets/icons/azure-devops.svg" height="40" alt="Azure DevOps"/><br/><sub>Azure DevOps</sub> | <img src="./docs/assets/icons/bitbucket.svg" height="40" alt="Bitbucket"/><br/><sub>Bitbucket</sub> | <img src="./docs/assets/icons/gitea.svg" height="40" alt="Gitea"/><br/><sub>Gitea</sub> |
|
||||
|
||||
| Docker | Jira | Confluence | Slack | AWS S3 |
|
||||
|:------:|:----:|:-----------:|:-----:|:------:|
|
||||
| <img src="./docs/assets/icons/docker.svg" height="40" alt="Docker"/><br/><sub>Docker</sub> | <img src="./docs/assets/icons/jira.svg" height="40" alt="Jira"/><br/><sub>Jira</sub> | <img src="./docs/assets/icons/confluence.svg" height="40" alt="Confluence"/><br/><sub>Confluence</sub> | <img src="./docs/assets/icons/slack.svg" height="40" alt="Slack"/><br/><sub>Slack</sub> | <img src="./docs/assets/icons/aws-s3.svg" height="40" alt="AWS S3"/><br/><sub>AWS S3</sub> |
|
||||
|
||||
</div>
|
||||
|
||||
### Performance, Accuracy, and Hundreds of Rules
|
||||
- **Performance**: multithreaded, Hyperscan‑powered scanning built for huge codebases
|
||||
- **Extensible rules**: hundreds of built-in detectors plus YAML-defined custom rules ([docs/RULES.md](/docs/RULES.md))
|
||||
- **Broad AI SaaS coverage**: finds and validates tokens for OpenAI, Anthropic, Google Gemini, Cohere, Mistral, Stability AI, Replicate, xAI (Grok), Ollama, Langchain, Perplexity, Weights & Biases, Cerebras, Friendli, Fireworks.ai, NVIDIA NIM, Together.ai, Zhipu, and many more
|
||||
|
|
@ -46,6 +43,8 @@ See ([docs/COMPARISON.md](docs/COMPARISON.md))
|
|||
|
||||
- [Kingfisher](#kingfisher)
|
||||
- [Key Features](#key-features)
|
||||
- [Multiple Scan Targets](#multiple-scan-targets)
|
||||
- [Performance, Accuracy, and Hundreds of Rules](#performance-accuracy-and-hundreds-of-rules)
|
||||
- [Benchmark Results](#benchmark-results)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Installation](#installation)
|
||||
|
|
@ -67,25 +66,30 @@ See ([docs/COMPARISON.md](docs/COMPARISON.md))
|
|||
- [Scan while ignoring likely test files](#scan-while-ignoring-likely-test-files)
|
||||
- [Exclude specific paths](#exclude-specific-paths)
|
||||
- [Scan changes in CI pipelines](#scan-changes-in-ci-pipelines)
|
||||
- [Scan an S3 bucket](#scan-an-s3-bucket)
|
||||
- [Scanning Docker Images](#scanning-docker-images)
|
||||
- [Scanning GitHub](#scanning-github)
|
||||
- [Scan GitHub organisation (requires `KF_GITHUB_TOKEN`)](#scan-github-organisation-requires-kf_github_token)
|
||||
- [ Scanning an AWS S3 Bucket](#-scanning-an-aws-s3-bucket)
|
||||
- [ Scanning Docker Images](#-scanning-docker-images)
|
||||
- [ Scanning GitHub](#-scanning-github)
|
||||
- [Scan GitHub organization (requires `KF_GITHUB_TOKEN`)](#scan-github-organization-requires-kf_github_token)
|
||||
- [Skip specific GitHub repositories during enumeration](#skip-specific-github-repositories-during-enumeration)
|
||||
- [Scan remote GitHub repository](#scan-remote-github-repository)
|
||||
- [Scanning GitLab](#scanning-gitlab)
|
||||
- [ Scanning GitLab](#-scanning-gitlab)
|
||||
- [Scan GitLab group (requires `KF_GITLAB_TOKEN`)](#scan-gitlab-group-requires-kf_gitlab_token)
|
||||
- [Scan GitLab user](#scan-gitlab-user)
|
||||
- [Skip specific GitLab projects during enumeration](#skip-specific-gitlab-projects-during-enumeration)
|
||||
- [Scan remote GitLab repository by URL](#scan-remote-gitlab-repository-by-url)
|
||||
- [List GitLab repositories](#list-gitlab-repositories)
|
||||
- [Scanning Gitea](#scanning-gitea)
|
||||
- [ Scanning Azure Repos](#-scanning-azure-repos)
|
||||
- [Scan Azure DevOps organization or collection (requires `KF_AZURE_TOKEN` or `KF_AZURE_PAT`)](#scan-azure-devops-organization-or-collection-requires-kf_azure_token-or-kf_azure_pat)
|
||||
- [Scan specific Azure DevOps projects](#scan-specific-azure-devops-projects)
|
||||
- [Skip specific Azure repositories during enumeration](#skip-specific-azure-repositories-during-enumeration)
|
||||
- [List Azure repositories](#list-azure-repositories)
|
||||
- [ Scanning Gitea](#-scanning-gitea)
|
||||
- [Scan Gitea organization (requires `KF_GITEA_TOKEN`)](#scan-gitea-organization-requires-kf_gitea_token)
|
||||
- [Scan Gitea user](#scan-gitea-user)
|
||||
- [Skip specific Gitea repositories during enumeration](#skip-specific-gitea-repositories-during-enumeration)
|
||||
- [Scan remote Gitea repository by URL](#scan-remote-gitea-repository-by-url)
|
||||
- [List Gitea repositories](#list-gitea-repositories)
|
||||
- [Scanning Bitbucket](#scanning-bitbucket)
|
||||
- [ Scanning Bitbucket](#-scanning-bitbucket)
|
||||
- [Scan Bitbucket workspace](#scan-bitbucket-workspace)
|
||||
- [Scan Bitbucket user](#scan-bitbucket-user)
|
||||
- [Skip specific Bitbucket repositories during enumeration](#skip-specific-bitbucket-repositories-during-enumeration)
|
||||
|
|
@ -93,12 +97,12 @@ See ([docs/COMPARISON.md](docs/COMPARISON.md))
|
|||
- [List Bitbucket repositories](#list-bitbucket-repositories)
|
||||
- [Authenticate to Bitbucket](#authenticate-to-bitbucket)
|
||||
- [Self-hosted Bitbucket Server](#self-hosted-bitbucket-server)
|
||||
- [Scanning Jira](#scanning-jira)
|
||||
- [ Scanning Jira](#-scanning-jira)
|
||||
- [Scan Jira issues matching a JQL query](#scan-jira-issues-matching-a-jql-query)
|
||||
- [Scan the last 1,000 Jira issues:](#scan-the-last-1000-jira-issues)
|
||||
- [Scanning Confluence](#scanning-confluence)
|
||||
- [ Scanning Confluence](#-scanning-confluence)
|
||||
- [Scan Confluence pages matching a CQL query](#scan-confluence-pages-matching-a-cql-query)
|
||||
- [Scanning Slack](#scanning-slack)
|
||||
- [ Scanning Slack](#-scanning-slack)
|
||||
- [Scan Slack messages matching a search query](#scan-slack-messages-matching-a-search-query)
|
||||
- [Environment Variables for Tokens](#environment-variables-for-tokens)
|
||||
- [Exit Codes](#exit-codes)
|
||||
|
|
@ -394,7 +398,8 @@ kingfisher scan ./my-project \
|
|||
--exclude tests \
|
||||
-v
|
||||
```
|
||||
## Scan an S3 bucket
|
||||
|
||||
## <img alt="GitHub" src="./docs/assets/icons/aws-s3.svg" width="20" height="20" style="vertical-align:text-bottom;"> Scanning an AWS S3 Bucket
|
||||
You can scan S3 objects directly:
|
||||
|
||||
```bash
|
||||
|
|
@ -445,7 +450,8 @@ docker run --rm \
|
|||
ghcr.io/mongodb/kingfisher:latest \
|
||||
scan --s3-bucket bucket-name
|
||||
```
|
||||
## Scanning Docker Images
|
||||
|
||||
## <img alt="Docker" src="./docs/assets/icons/docker.svg" width="20" height="20" style="vertical-align:text-bottom;"> Scanning Docker Images
|
||||
|
||||
Kingfisher will first try to use any locally available image, then fall back to pulling via OCI.
|
||||
|
||||
|
|
@ -475,9 +481,9 @@ kingfisher scan --docker-image some-private-registry.dkr.ecr.us-east-1.amazonaws
|
|||
kingfisher scan --docker-image private.registry.example.com/my-image:tag
|
||||
```
|
||||
|
||||
## Scanning GitHub
|
||||
## <img alt="GitHub" src="./docs/assets/icons/github.svg" width="20" height="20" style="vertical-align:text-bottom;"> Scanning GitHub
|
||||
|
||||
### Scan GitHub organisation (requires `KF_GITHUB_TOKEN`)
|
||||
### Scan GitHub organization (requires `KF_GITHUB_TOKEN`)
|
||||
|
||||
```bash
|
||||
kingfisher scan --github-organization my-org
|
||||
|
|
@ -517,7 +523,7 @@ KF_GITHUB_TOKEN="ghp_…" kingfisher scan --git-url https://github.com/org/priva
|
|||
|
||||
---
|
||||
|
||||
## Scanning GitLab
|
||||
## <img alt="GitLab" src="./docs/assets/icons/gitlab.svg" width="20" height="20" style="vertical-align:text-bottom;"> Scanning GitLab
|
||||
|
||||
### Scan GitLab group (requires `KF_GITLAB_TOKEN`)
|
||||
|
||||
|
|
@ -573,8 +579,48 @@ kingfisher gitlab repos list --group my-group --include-subgroups
|
|||
# skip specific projects when listing or scanning (supports glob patterns)
|
||||
kingfisher gitlab repos list --group my-group --gitlab-exclude my-group/**/legacy-*
|
||||
```
|
||||
## <img alt="Azure Repos" src="./docs/assets/icons/azure-devops.svg" width="20" height="20" style="vertical-align:text-bottom;"> Scanning Azure Repos
|
||||
|
||||
## Scanning Gitea
|
||||
### Scan Azure DevOps organization or collection (requires `KF_AZURE_TOKEN` or `KF_AZURE_PAT`)
|
||||
|
||||
```bash
|
||||
kingfisher scan --azure-organization my-org
|
||||
|
||||
# Azure DevOps Server example
|
||||
KF_AZURE_PAT="pat" kingfisher scan --azure-organization DefaultCollection --azure-base-url https://ado.internal.example/tfs/
|
||||
```
|
||||
|
||||
### Scan specific Azure DevOps projects
|
||||
|
||||
Projects are specified as `ORGANIZATION/PROJECT`. Repeat the flag for multiple projects.
|
||||
|
||||
```bash
|
||||
kingfisher scan --azure-project my-org/payments --azure-project my-org/core-platform
|
||||
```
|
||||
|
||||
### Skip specific Azure repositories during enumeration
|
||||
|
||||
Repeat `--azure-exclude` to ignore repositories when scanning organizations or projects.
|
||||
Use identifiers like `ORGANIZATION/PROJECT/REPOSITORY`. Repositories that share the same
|
||||
name as their project can be excluded with `ORGANIZATION/PROJECT`, and gitignore-style
|
||||
patterns such as `my-org/*/archive-*` are also supported.
|
||||
|
||||
```bash
|
||||
kingfisher scan --azure-organization my-org \
|
||||
--azure-exclude my-org/payments/legacy-service \
|
||||
--azure-exclude my-org/**/archive-*
|
||||
```
|
||||
|
||||
### List Azure repositories
|
||||
|
||||
```bash
|
||||
kingfisher azure repos list --organization my-org
|
||||
# list repositories for specific projects
|
||||
kingfisher azure repos list --project my-org/app --project my-org/api
|
||||
# skip specific repositories while listing (supports glob patterns)
|
||||
kingfisher azure repos list --organization my-org --azure-exclude my-org/**/experimental-*
|
||||
```
|
||||
## <img alt="Gitea" src="./docs/assets/icons/gitea.svg" width="20" height="20" style="vertical-align:text-bottom;"> Scanning Gitea
|
||||
|
||||
### Scan Gitea organization (requires `KF_GITEA_TOKEN`)
|
||||
|
||||
|
|
@ -626,9 +672,7 @@ KF_GITEA_TOKEN="gtoken" kingfisher gitea repos list --all-gitea-organizations
|
|||
# self-hosted example
|
||||
KF_GITEA_TOKEN="gtoken" kingfisher gitea repos list --user johndoe --gitea-api-url https://gitea.internal.example/api/v1/
|
||||
```
|
||||
|
||||
## Scanning Bitbucket
|
||||
|
||||
## <img alt="Bitbucket" src="./docs/assets/icons/bitbucket.svg" width="20" height="20" style="vertical-align:text-bottom;"> Scanning Bitbucket
|
||||
### Scan Bitbucket workspace
|
||||
|
||||
```bash
|
||||
|
|
@ -700,8 +744,7 @@ Use `--bitbucket-api-url` to point Kingfisher at your server's REST endpoint, fo
|
|||
`https://bitbucket.example.com/rest/api/1.0/`. Provide credentials with
|
||||
`--bitbucket-username` and `--bitbucket-token`, and pass `--ignore-certs` when
|
||||
connecting to HTTP or otherwise insecure instances.
|
||||
|
||||
## Scanning Jira
|
||||
## <img alt="Jira" src="./docs/assets/icons/jira.svg" width="20" height="20" style="vertical-align:text-bottom;"> Scanning Jira
|
||||
|
||||
### Scan Jira issues matching a JQL query
|
||||
|
||||
|
|
@ -720,8 +763,7 @@ KF_JIRA_TOKEN="token" kingfisher scan \
|
|||
--max-results 1000
|
||||
```
|
||||
|
||||
## Scanning Confluence
|
||||
|
||||
## <img alt="Confluence" src="./docs/assets/icons/confluence.svg" width="20" height="20" style="vertical-align:text-bottom;"> Scanning Confluence
|
||||
### Scan Confluence pages matching a CQL query
|
||||
|
||||
```bash
|
||||
|
|
@ -746,8 +788,7 @@ Generate a personal access token and set it in the `KF_CONFLUENCE_TOKEN` environ
|
|||
|
||||
To use basic authentication instead, also set `KF_CONFLUENCE_USER` to your Confluence email address; Kingfisher will then send the username and `KF_CONFLUENCE_TOKEN` as a Basic auth header. If the server responds with a redirect to a login page, the credentials are invalid or lack the required permissions.
|
||||
|
||||
## Scanning Slack
|
||||
|
||||
## <img alt="Slack" src="./docs/assets/icons/slack.svg" width="20" height="20" style="vertical-align:text-bottom;"> Scanning Slack
|
||||
### Scan Slack messages matching a search query
|
||||
|
||||
```bash
|
||||
|
|
@ -769,6 +810,8 @@ KF_SLACK_TOKEN="xoxp-1234..." kingfisher scan \
|
|||
| `KF_GITLAB_TOKEN` | GitLab Personal Access Token |
|
||||
| `KF_GITEA_TOKEN` | Gitea Personal Access Token |
|
||||
| `KF_GITEA_USERNAME` | Username for private Gitea clones (used with `KF_GITEA_TOKEN`) |
|
||||
| `KF_AZURE_TOKEN` / `KF_AZURE_PAT` | Azure DevOps Personal Access Token |
|
||||
| `KF_AZURE_USERNAME` | Username to use with Azure DevOps PATs (defaults to `pat` when unset) |
|
||||
| `KF_BITBUCKET_USERNAME` | Bitbucket username for basic authentication |
|
||||
| `KF_BITBUCKET_APP_PASSWORD` / `KF_BITBUCKET_TOKEN` | Bitbucket app password or server token |
|
||||
| `KF_BITBUCKET_OAUTH_TOKEN` | Bitbucket OAuth or PAT token |
|
||||
|
|
@ -971,14 +1014,16 @@ kingfisher scan --help
|
|||
Kingfisher began as a fork of Praetorian’s Nosey Parker, as our experiment with adding live validation support and embedding that validation directly inside each rule.
|
||||
|
||||
Since that initial fork, it has diverged heavily from Nosey Parker:
|
||||
- Replaced the SQLite datastore with an in-memory store + Bloom filter
|
||||
- Collapsed the workflow into a single scan-and-report phase with direct JSON/BSON/SARIF outputs
|
||||
- Added Tree-Sitter parsing on top of Hyperscan for deeper language-aware detection
|
||||
- Removed datastore-driven reporting/annotations in favor of live validation, baselines, allowlists, and compressed-file extraction
|
||||
- Added support for live validation of discovered secrets
|
||||
- Added hundreds of new rules
|
||||
- Added support for analyzing compressed files
|
||||
- Added support for building "baselines" to allow for only reporting on newly discovered secrets
|
||||
- Added Tree-Sitter based source code parsing on top of Hyperscan for deeper language-aware detection
|
||||
- Expanded support for new targets (GitLab, BitBucket, Gitea, Jira, Confluence, Slack, S3, Docker, etc.)
|
||||
- Replaced the SQLite datastore with an in-memory store + Bloom filter
|
||||
- Collapsed the workflow into a single scan-and-report phase with direct JSON/BSON/SARIF outputs
|
||||
- Delivered cross-platform builds, including native Windows
|
||||
|
||||
|
||||
# Roadmap
|
||||
|
||||
- More rules
|
||||
|
|
|
|||
13
buildwin.bat
|
|
@ -86,9 +86,10 @@ if /I not "%LOCALAPPDATA:~1,1%"==":" (
|
|||
)
|
||||
|
||||
REM ── Install Hyperscan ------------------------------------------------------
|
||||
echo Installing Hyperscan via vcpkg...
|
||||
set "VCPKG_TRIPLET=x64-windows-static"
|
||||
echo Installing Hyperscan (%VCPKG_TRIPLET%) via vcpkg...
|
||||
pushd "%HOMEDRIVE%\vcpkg" REM ► work inside the vcpkg root
|
||||
"%VCPKG_EXE%" install hyperscan:x64-windows || (
|
||||
"%VCPKG_EXE%" install hyperscan:%VCPKG_TRIPLET% || (
|
||||
echo ERROR: vcpkg install failed.
|
||||
popd
|
||||
exit /b 1
|
||||
|
|
@ -97,7 +98,7 @@ popd
|
|||
set "LIBHS_NO_PKG_CONFIG=1"
|
||||
|
||||
REM Point vectorscan‑rs‑sys at the Hyperscan install
|
||||
set "HYPERSCAN_ROOT=%HOMEDRIVE%\vcpkg\installed\x64-windows"
|
||||
set "HYPERSCAN_ROOT=%HOMEDRIVE%\vcpkg\installed\%VCPKG_TRIPLET%"
|
||||
set "LIB=%HYPERSCAN_ROOT%\lib;%LIB%"
|
||||
set "INCLUDE=%HYPERSCAN_ROOT%\include;%INCLUDE%"
|
||||
|
||||
|
|
@ -113,7 +114,9 @@ if %ERRORLEVEL% NEQ 0 (
|
|||
echo Rust is already installed.
|
||||
)
|
||||
|
||||
echo Building for Windows x64...
|
||||
set "RUSTFLAGS=%RUSTFLAGS% -C target-feature=+crt-static"
|
||||
|
||||
echo Building static Windows x64 binary...
|
||||
cargo build --release --target x86_64-pc-windows-msvc || (
|
||||
echo Cargo build failed.
|
||||
exit /b 1
|
||||
|
|
@ -144,4 +147,4 @@ echo Archives in target\release:
|
|||
dir /b *.zip 2>nul || echo None found.
|
||||
|
||||
endlocal
|
||||
exit /b 0
|
||||
exit /b 0
|
||||
|
|
|
|||
|
|
@ -1,13 +1,27 @@
|
|||
rules:
|
||||
- name: Azure DevOps Personal Access Token
|
||||
- name: Azure DevOps Organization
|
||||
id: kingfisher.azure.devops.1
|
||||
pattern: |
|
||||
(?xi)
|
||||
\b
|
||||
azure
|
||||
(?:.|[\n\r]){0,32}?
|
||||
dev\.azure\.com/
|
||||
(
|
||||
[a-z0-9]{75}AZDO[a-z0-9]{5}
|
||||
[a-z0-9][a-z0-9-]{0,61}[a-z0-9]
|
||||
)
|
||||
confidence: medium
|
||||
min_entropy: 2.5
|
||||
visible: false
|
||||
examples:
|
||||
- https://dev.azure.com/contoso
|
||||
- dev.azure.com/somebody123
|
||||
|
||||
- name: Azure DevOps Personal Access Token
|
||||
id: kingfisher.azure.devops.2
|
||||
pattern: |
|
||||
(?xi)
|
||||
\b
|
||||
(
|
||||
[a-z0-9]{75,76}AZDO[a-z0-9]{4,5}
|
||||
)
|
||||
\b
|
||||
min_entropy: 3
|
||||
|
|
@ -17,16 +31,20 @@ rules:
|
|||
references:
|
||||
- https://learn.microsoft.com/en-us/rest/api/azure/devops/profile/profiles/get?view=azure-devops-rest-7.1&tabs=HTTP
|
||||
- https://learn.microsoft.com/en-us/azure/devops/release-notes/2024/general/sprint-241-update
|
||||
depends_on_rule:
|
||||
- rule_id: kingfisher.azure.devops.1
|
||||
variable: AZURE_DEVOPS_ORG
|
||||
validation:
|
||||
type: Http
|
||||
content:
|
||||
request:
|
||||
headers:
|
||||
Authorization: 'Basic {{ ":" | append: TOKEN | b64enc }}'
|
||||
Accept: application/json
|
||||
method: GET
|
||||
url: https://app.vssps.visualstudio.com/_apis/profile/profiles/me?api-version=7.1-preview.1
|
||||
url: "https://dev.azure.com/{{ AZURE_DEVOPS_ORG | split: '/' | last }}/_apis/projects?api-version=7.1-preview.1"
|
||||
response_matcher:
|
||||
- report_response: true
|
||||
- type: StatusMatch
|
||||
status:
|
||||
- 200
|
||||
- 200
|
||||
|
|
@ -4,8 +4,8 @@ rules:
|
|||
pattern: |
|
||||
(?xi)
|
||||
\b
|
||||
(?:datadog|dd-|dd_)
|
||||
(?:.|[\n\r]){0,16}?
|
||||
datadog
|
||||
(?:.|[\n\r]){0,64}?
|
||||
(?:SECRET|PRIVATE|ACCESS|KEY|TOKEN)
|
||||
(?:.|[\n\r]){0,32}?
|
||||
\b
|
||||
|
|
@ -16,7 +16,6 @@ rules:
|
|||
min_entropy: 3.3
|
||||
confidence: medium
|
||||
examples:
|
||||
- dd-apikey-dd52c29224affe29d163c6bf99e5c34f
|
||||
- datadog-secrettoken-0024a29224affe29d173c0bf99e5a89d
|
||||
references:
|
||||
- https://docs.datadoghq.com/account_management/api-app-keys/
|
||||
|
|
@ -45,7 +44,7 @@ rules:
|
|||
(?xi)
|
||||
\b
|
||||
datadog
|
||||
(?:.|[\n\r]){0,16}?
|
||||
(?:.|[\n\r]){0,64}?
|
||||
(?:SECRET|PRIVATE|ACCESS|KEY|TOKEN)
|
||||
(?:.|[\n\r]){0,16}?
|
||||
\b
|
||||
|
|
|
|||
34
docs/assets/icons/aws-s3.svg
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="428" height="512" viewBox="0 0 428 512">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #e25444;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2, .cls-3 {
|
||||
fill-rule: evenodd;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #7b1d13;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #58150d;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M378,99L295,257l83,158,34-19V118Z"/>
|
||||
<path class="cls-2" d="M378,99L212,118,127.5,257,212,396l166,19V99Z"/>
|
||||
<path class="cls-3" d="M43,99L16,111V403l27,12L212,257Z"/>
|
||||
<path class="cls-1" d="M42.637,98.667l169.587,47.111V372.444L42.637,415.111V98.667Z"/>
|
||||
<path class="cls-3" d="M212.313,170.667l-72.008-11.556,72.008-81.778,71.83,81.778Z"/>
|
||||
<path class="cls-3" d="M284.143,159.111l-71.919,11.733-71.919-11.733V77.333"/>
|
||||
<path class="cls-3" d="M212.313,342.222l-72.008,13.334,72.008,70.222,71.83-70.222Z"/>
|
||||
<path class="cls-2" d="M212,16L140,54V159l72.224-20.333Z"/>
|
||||
<path class="cls-2" d="M212.224,196.444l-71.919,7.823V309.105l71.919,8.228V196.444Z"/>
|
||||
<path class="cls-2" d="M212.224,373.333L140.305,355.3V458.363L212.224,496V373.333Z"/>
|
||||
<path class="cls-1" d="M284.143,355.3l-71.919,18.038V496l71.919-37.637V355.3Z"/>
|
||||
<path class="cls-1" d="M212.224,196.444l71.919,7.823V309.105l-71.919,8.228V196.444Z"/>
|
||||
<path class="cls-1" d="M212,16l72,38V159l-72-20V16Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
2
docs/assets/icons/azure-devops.svg
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none"><path fill="#000000" d="M15 3.622v8.512L11.5 15l-5.425-1.975v1.958L3.004 10.97l8.951.7V4.005L15 3.622zm-2.984.428L6.994 1v2.001L2.382 4.356 1 6.13v4.029l1.978.873V5.869l9.038-1.818z"/></svg>
|
||||
|
After Width: | Height: | Size: 410 B |
15
docs/assets/icons/bitbucket.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="500" height="500" viewBox="0 0 62.42 62.42">
|
||||
<defs>
|
||||
<linearGradient id="New_Gradient_Swatch_1" x1="64.01" y1="30.27" x2="32.99" y2="54.48" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.18" stop-color="#0052cc" />
|
||||
<stop offset="1" stop-color="#2684ff" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<title>Bitbucket-blue</title>
|
||||
<g id="Layer_2" data-name="Layer 2" >
|
||||
<g id="Blue" transform="translate(0 -3.13)">
|
||||
<path d="M2,6.26A2,2,0,0,0,0,8.58L8.49,60.12a2.72,2.72,0,0,0,2.66,2.27H51.88a2,2,0,0,0,2-1.68L62.37,8.59a2,2,0,0,0-2-2.32ZM37.75,43.51h-13L21.23,25.12H40.9Z" fill="#2684ff" />
|
||||
<path d="M59.67,25.12H40.9L37.75,43.51h-13L9.4,61.73a2.71,2.71,0,0,0,1.75.66H51.89a2,2,0,0,0,2-1.68Z" fill="url(#New_Gradient_Swatch_1)"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 930 B |
1
docs/assets/icons/confluence.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg height="2400" viewBox="-.02238712 .04 256.07238712 245.94" width="2500" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a"><stop offset="0" stop-color="#0052cc"/><stop offset=".92" stop-color="#2380fb"/><stop offset="1" stop-color="#2684ff"/></linearGradient><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="243.35" x2="83.149" xlink:href="#a" y1="261.618" y2="169.549"/><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="12.633" x2="172.873" xlink:href="#a" y1="-15.48" y2="76.589"/><path d="m9.11 187.79c-2.64 4.3-5.63 9.34-7.99 13.33-.52.89-.85 1.88-1 2.9a8.13 8.13 0 0 0 .16 3.07c.24 1.01.68 1.95 1.28 2.79s1.36 1.56 2.23 2.12l53.03 32.69c.91.57 1.94.95 3.01 1.12 1.06.17 2.16.13 3.21-.13s2.04-.72 2.91-1.36 1.6-1.45 2.15-2.38c2.14-3.56 4.85-8.17 7.76-13.09 21.02-34.47 42.32-30.25 80.37-12.16l52.6 24.94a8.13 8.13 0 0 0 6.35.29c1.02-.38 1.96-.96 2.75-1.71.8-.75 1.43-1.65 1.87-2.65l25.25-56.93c.43-.96.67-1.99.7-3.04.04-1.04-.13-2.09-.49-3.07s-.9-1.89-1.6-2.67-1.54-1.41-2.49-1.88c-11.09-5.22-33.16-15.49-52.94-25.17-71.95-34.71-132.66-32.42-179.12 42.99z" fill="url(#b)"/><path d="m246.88 58.38c2.67-4.3 5.66-9.33 7.99-13.32.53-.91.88-1.92 1.03-2.97.15-1.04.09-2.11-.17-3.13a8.155 8.155 0 0 0 -1.36-2.83 8.09 8.09 0 0 0 -2.33-2.11l-52.95-32.69c-.92-.57-1.94-.95-3.01-1.12s-2.16-.12-3.21.13c-1.05.26-2.04.72-2.91 1.36s-1.6 1.45-2.16 2.38c-2.09 3.56-4.85 8.17-7.76 13.09-21.1 34.63-42.2 30.41-80.29 12.32l-52.55-24.95c-.98-.47-2.04-.75-3.12-.81-1.08-.07-2.17.09-3.19.45s-1.96.92-2.76 1.65c-.81.73-1.45 1.61-1.91 2.59l-25.25 57.09a8.191 8.191 0 0 0 -.23 6.13c.36.99.91 1.9 1.61 2.68s1.55 1.42 2.5 1.88c11.13 5.23 33.2 15.49 52.94 25.18 71.76 34.7 132.66 32.42 179.09-43z" fill="url(#c)"/></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
1
docs/assets/icons/docker.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="64" height="64" viewBox="0 0 32 32"><defs><clipPath id="A"><path d="M76 2v46H54v23H35.58l-.206 2c-1.15 12.53 1.036 24.088 6.063 33.97l1.688 3.03c1 1.817 2.2 3.523 3.438 5.188s1.686 2.583 2.47 3.688C62.32 133.8 82.13 141 105 141c50.648 0 93.633-22.438 112.656-72.844C231.153 69.54 244.1 66.08 250 54.563c-9.4-5.424-21.478-3.685-28.437-.187L240 2l-72 46h-23V2z"/></clipPath></defs><g transform="matrix(.679423 0 0 .679423 -2.086149 13.781817)"><path d="M30.305-3.553h4.222V.763h2.135a9.26 9.26 0 0 0 2.934-.492c.46-.156.974-.372 1.426-.644-.596-.778-.9-1.76-1-2.73-.122-1.317.144-3.032 1.036-4.063l.444-.513.53.425c1.332 1.07 2.452 2.565 2.65 4.27 1.603-.472 3.486-.36 4.9.456l.58.335-.305.596c-1.187 2.33-3.687 3.053-6.13 2.925-3.656 9.105-11.615 13.416-21.265 13.416-4.986 0-9.56-1.864-12.164-6.287-.153-.275-.283-.562-.422-.844-.88-1.947-1.173-4.08-.975-6.2l.06-.638h3.6V-3.55h4.222v-4.222h8.445v-4.222h5.067v8.445" fill="#394d54"/><g transform="matrix(.184659 0 0 .184659 3.070472 -11.997864)" clip-path="url(#A)"><g id="B"><g id="C" transform="translate(0 -22.866)"><path d="M123.86 3.8h19.818v19.817H123.86z" fill="#00acd3"/><path d="M123.86 26.676h19.818v19.818H123.86z" fill="#20c2ef"/><path id="D" d="M126.292 21.977V5.46m2.972 16.516V5.46m3.002 16.516V5.46m3.003 16.516V5.46m3.003 16.516V5.46m2.97 16.516V5.46" stroke="#394d54" stroke-width="1.56"/><use xlink:href="#D" y="22.866"/></g><use xlink:href="#C" transform="matrix(1 0 0 -1 22.866 4.572651)"/></g><use xlink:href="#B" x="-91.464" y="45.732"/><use xlink:href="#B" x="-45.732" y="45.732"/><use xlink:href="#B" y="45.732"/><path d="M221.57 54.38c1.533-11.915-7.384-21.275-12.914-25.718-6.373 7.368-7.363 26.678 2.635 34.807-5.58 4.956-17.337 9.448-29.376 9.448H34C32.83 85.484 34 146 34 146h217l-.987-91.424c-9.4-5.424-21.484-3.694-28.443-.197" fill="#17b5eb"/><path d="M34 89v57h217V89" fill-opacity=".17"/><path d="M111.237 140.9c-13.54-6.425-20.972-15.16-25.107-24.694L45 118l21 28 45.237-5.1" fill="#d4edf1"/><path d="M222.5 53.938v.03c-20.86 26.9-50.783 50.38-82.906 62.72-28.655 11.008-53.638 11.06-70.875 2.22-1.856-1.048-3.676-2.212-5.5-3.312-12.637-8.832-19.754-23.44-19.156-42.687H34V146h217V50h-25z" fill-opacity=".085"/></g><path d="M11.496 9.613c2.616.143 5.407.17 7.842-.594" fill="none" stroke="#394d54" stroke-width=".628" stroke-linecap="round"/><path d="M21.937 7.753a1.01 1.01 0 0 1-1.009 1.009 1.01 1.01 0 0 1-1.01-1.009 1.01 1.01 0 0 1 1.01-1.01 1.01 1.01 0 0 1 1.009 1.01z" fill="#d4edf1"/><path d="M21.2 7.08c-.088.05-.148.146-.148.256 0 .163.132.295.295.295.112 0 .2-.062.26-.154a.73.73 0 0 1 .055.277c0 .4-.324.723-.723.723s-.723-.324-.723-.723.324-.723.723-.723a.72.72 0 0 1 .262.049zM3.07 4.65h46.964c-1.023-.26-3.235-.6-2.87-1.95-1.86 2.152-6.344 1.5-7.475.448-1.26 1.828-8.597 1.133-9.108-.3-1.58 1.854-6.475 1.854-8.055 0-.512 1.424-7.848 2.12-9.1.3C12.284 4.2 7.8 4.853 5.94 2.7c.365 1.34-1.848 1.7-2.87 1.95" fill="#394d54"/></g></svg>
|
||||
|
After Width: | Height: | Size: 3 KiB |
67
docs/assets/icons/files.svg
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
<svg id="svg97" xmlns="http://www.w3.org/2000/svg" height="48" width="48" version="1.0" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<defs id="defs3">
|
||||
<radialGradient id="radialGradient6719" xlink:href="#linearGradient5060" gradientUnits="userSpaceOnUse" cy="486.65" cx="605.71" gradientTransform="matrix(-2.7744 0 0 1.9697 112.76 -872.89)" r="117.14"/>
|
||||
<linearGradient id="linearGradient5060">
|
||||
<stop id="stop5062" offset="0"/>
|
||||
<stop id="stop5064" stop-opacity="0" offset="1"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="radialGradient6717" xlink:href="#linearGradient5060" gradientUnits="userSpaceOnUse" cy="486.65" cx="605.71" gradientTransform="matrix(2.7744 0 0 1.9697 -1891.6 -872.89)" r="117.14"/>
|
||||
<linearGradient id="linearGradient6715" y2="609.51" gradientUnits="userSpaceOnUse" x2="302.86" gradientTransform="matrix(2.7744 0 0 1.9697 -1892.2 -872.89)" y1="366.65" x1="302.86">
|
||||
<stop id="stop5050" stop-opacity="0" offset="0"/>
|
||||
<stop id="stop5056" offset=".5"/>
|
||||
<stop id="stop5052" stop-opacity="0" offset="1"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="radialGradient238" gradientUnits="userSpaceOnUse" cy="37.518" cx="20.706" gradientTransform="matrix(1.055 -.027345 .17770 1.1909 -3.5722 -7.1253)" r="30.905">
|
||||
<stop id="stop1790" stop-color="#202020" offset="0"/>
|
||||
<stop id="stop1791" stop-color="#b9b9b9" offset="1"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="linearGradient491" y2="66.834" gradientUnits="userSpaceOnUse" x2="9.8981" gradientTransform="matrix(1.5168 0 0 .70898 -.87957 -1.3182)" y1="13.773" x1="6.2298">
|
||||
<stop id="stop3984" stop-color="#fff" stop-opacity=".87629" offset="0"/>
|
||||
<stop id="stop3985" stop-color="#fffffe" stop-opacity="0" offset="1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linearGradient322" y2="46.689" gradientUnits="userSpaceOnUse" x2="12.854" gradientTransform="matrix(1.3175 0 0 .81626 -.87957 -1.3182)" y1="32.567" x1="13.036">
|
||||
<stop id="stop320" stop-color="#fff" offset="0"/>
|
||||
<stop id="stop321" stop-color="#fff" stop-opacity="0" offset="1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linearGradient3104" y2="6.1803" gradientUnits="userSpaceOnUse" x2="15.515" y1="31.368" x1="18.113">
|
||||
<stop id="stop3098" stop-color="#424242" offset="0"/>
|
||||
<stop id="stop3100" stop-color="#777" offset="1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linearGradient9772" y2="32.05" gradientUnits="userSpaceOnUse" x2="22.065" y1="36.988" x1="22.176">
|
||||
<stop id="stop9768" stop-color="#6194cb" offset="0"/>
|
||||
<stop id="stop9770" stop-color="#729fcf" offset="1"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="layer1">
|
||||
<g id="g6707" transform="matrix(.022624 0 0 .020868 43.383 36.37)">
|
||||
<rect id="rect6709" opacity=".40206" height="478.36" width="1339.6" y="-150.7" x="-1559.3" fill="url(#linearGradient6715)"/>
|
||||
<path id="path6711" opacity=".40206" d="m-219.62-150.68v478.33c142.88 0.9 345.4-107.17 345.4-239.2 0-132.02-159.44-239.13-345.4-239.13z" fill="url(#radialGradient6717)"/>
|
||||
<path id="path6713" opacity=".40206" d="m-1559.3-150.68v478.33c-142.8 0.9-345.4-107.17-345.4-239.2 0-132.02 159.5-239.13 345.4-239.13z" fill="url(#radialGradient6719)"/>
|
||||
</g>
|
||||
<path id="path216" stroke-linejoin="round" d="m4.5218 38.687c0.0218 0.417 0.4599 0.833 0.8762 0.833h31.327c0.416 0 0.811-0.416 0.789-0.833l-0.936-27.226c-0.022-0.417-0.46-0.833-0.877-0.833h-13.27c-0.486 0-1.235-0.316-1.402-1.1066l-0.612-2.893c-0.155-0.7357-0.882-1.0379-1.298-1.0379h-14.779c-0.4162 0-0.8107 0.4163-0.7889 0.8326l0.9707 32.264z" stroke="url(#linearGradient3104)" stroke-linecap="round" fill="url(#radialGradient238)"/>
|
||||
<path id="path9788" opacity=".11364" stroke-linejoin="round" d="m5.2266 22.562h30.265" stroke="#000" stroke-linecap="round" fill="#729fcf"/>
|
||||
<path id="path9784" opacity=".11364" stroke-linejoin="round" d="m5.0422 18.562h30.447" stroke="#000" stroke-linecap="round" fill="#729fcf"/>
|
||||
<path id="path9778" opacity=".11364" stroke-linejoin="round" d="m4.9807 12.562h30.507" stroke="#000" stroke-linecap="round" fill="#729fcf"/>
|
||||
<path id="path9798" opacity=".11364" stroke-linejoin="round" d="m5.3862 32.562h30.109" stroke="#000" stroke-linecap="round" fill="#729fcf"/>
|
||||
<path id="path9800" opacity=".11364" stroke-linejoin="round" d="m5.5091 34.562h29.988" stroke="#000" stroke-linecap="round" fill="#729fcf"/>
|
||||
<path id="path9782" opacity=".11364" stroke-linejoin="round" d="m5.0422 16.562h30.447" stroke="#000" stroke-linecap="round" fill="#729fcf"/>
|
||||
<path id="path9780" opacity=".11364" stroke-linejoin="round" d="m5.0114 14.562h30.478" stroke="#000" stroke-linecap="round" fill="#729fcf"/>
|
||||
<path id="path9776" opacity=".11364" stroke-linejoin="round" d="m4.9221 10.562h15.281" stroke="#000" stroke-linecap="round" fill="#729fcf"/>
|
||||
<path id="path9774" opacity=".11364" stroke-linejoin="round" d="m4.8738 8.5625h14.783" stroke="#000" stroke-linecap="round" fill="#729fcf"/>
|
||||
<path id="path9794" opacity=".11364" stroke-linejoin="round" d="m5.3247 28.562h30.169" stroke="#000" stroke-linecap="round" fill="#729fcf"/>
|
||||
<path id="path9792" opacity=".11364" stroke-linejoin="round" d="m5.2881 26.562h30.205" stroke="#000" stroke-linecap="round" fill="#729fcf"/>
|
||||
<path id="path9790" opacity=".11364" stroke-linejoin="round" d="m5.2266 24.562h30.265" stroke="#000" stroke-linecap="round" fill="#729fcf"/>
|
||||
<path id="path9786" opacity=".11364" stroke-linejoin="round" d="m5.1959 20.562h30.296" stroke="#000" stroke-linecap="round" fill="#729fcf"/>
|
||||
<path id="path9796" opacity=".11364" stroke-linejoin="round" d="m5.3247 30.562h30.169" stroke="#000" stroke-linecap="round" fill="#729fcf"/>
|
||||
<path id="path9802" opacity=".11364" stroke-linejoin="round" d="m5.5091 36.562h29.988" stroke="#000" stroke-linecap="round" fill="#729fcf"/>
|
||||
<path id="path219" opacity=".45143" d="m6.0683 38.864c0.0164 0.312-0.1809 0.52-0.4985 0.416-0.3177-0.104-0.5368-0.312-0.5531-0.624l-0.9477-32.065c-0.0164-0.3118 0.1651-0.5004 0.4774-0.5004l14.422-0.0477c0.313 0 0.932 0.3005 1.133 1.3222l0.574 2.8159c-0.427-0.4656-0.419-0.48-0.638-1.1571l-0.406-1.2592c-0.219-0.7276-0.698-0.8319-1.01-0.8319h-12.888c-0.3122 0-0.5095 0.2082-0.4931 0.5204l0.938 31.515-0.1096-0.104z" display="block" fill="url(#linearGradient491)"/>
|
||||
<g id="g220" fill-opacity=".75706" transform="matrix(1.0408 0 .054493 1.0408 -8.6702 2.6706)" fill="#fff">
|
||||
<path id="path221" fill-opacity=".50847" fill="#fff" d="m42.417 8.5152c0.005-0.0971-0.128-0.247-0.235-0.247l-13.031-0.0021s0.911 0.5879 2.201 0.5962l11.054 0.071c0.011-0.2117 0.003-0.256 0.011-0.4181z"/>
|
||||
</g>
|
||||
<path id="path233" stroke-linejoin="round" d="m39.784 39.511c1.143-0.044 1.963-1.097 2.047-2.321 0.791-11.549 1.659-21.232 1.659-21.232 0.072-0.248-0.168-0.495-0.48-0.495h-34.371c-0.0004 0-1.8507 21.867-1.8507 21.867-0.1145 0.982-0.466 1.804-1.5498 2.183l34.546-0.002z" display="block" stroke="#3465a4" fill="url(#linearGradient9772)"/>
|
||||
<path id="path304" opacity=".46591" d="m9.6202 16.464l32.791 0.065-1.574 20.002c-0.084 1.071-0.45 1.428-1.872 1.428-1.872 0-28.678-0.032-31.395-0.032 0.2335-0.321 0.3337-0.989 0.335-1.005l1.7152-20.458z" stroke="url(#linearGradient322)" stroke-linecap="round" stroke-width="1px" fill="none"/>
|
||||
<path id="path323" d="m9.6202 16.223l-1.1666 15.643s8.2964-4.148 18.666-4.148 15.555-11.495 15.555-11.495h-33.055z" fill-opacity=".089286" fill-rule="evenodd" fill="#fff"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.3 KiB |
1
docs/assets/icons/gitea.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg version="1.1" id="main_outline" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" style="enable-background:new 0 0 640 640;" xml:space="preserve" viewBox="5.67 143.05 628.65 387.55"> <g> <path id="teabag" style="fill:#FFFFFF" d="M395.9,484.2l-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5,21.2-17.9,33.8-11.8 c17.2,8.3,27.1,13,27.1,13l-0.1-109.2l16.7-0.1l0.1,117.1c0,0,57.4,24.2,83.1,40.1c3.7,2.3,10.2,6.8,12.9,14.4 c2.1,6.1,2,13.1-1,19.3l-61,126.9C423.6,484.9,408.4,490.3,395.9,484.2z"></path> <g> <g> <path style="fill:#609926" d="M622.7,149.8c-4.1-4.1-9.6-4-9.6-4s-117.2,6.6-177.9,8c-13.3,0.3-26.5,0.6-39.6,0.7c0,39.1,0,78.2,0,117.2 c-5.5-2.6-11.1-5.3-16.6-7.9c0-36.4-0.1-109.2-0.1-109.2c-29,0.4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5 c-9.8-0.6-22.5-2.1-39,1.5c-8.7,1.8-33.5,7.4-53.8,26.9C-4.9,212.4,6.6,276.2,8,285.8c1.7,11.7,6.9,44.2,31.7,72.5 c45.8,56.1,144.4,54.8,144.4,54.8s12.1,28.9,30.6,55.5c25,33.1,50.7,58.9,75.7,62c63,0,188.9-0.1,188.9-0.1s12,0.1,28.3-10.3 c14-8.5,26.5-23.4,26.5-23.4s12.9-13.8,30.9-45.3c5.5-9.7,10.1-19.1,14.1-28c0,0,55.2-117.1,55.2-231.1 C633.2,157.9,624.7,151.8,622.7,149.8z M125.6,353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6,321.8,60,295.4 c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5,38.5-30c13.8-3.7,31-3.1,31-3.1s7.1,59.4,15.7,94.2c7.2,29.2,24.8,77.7,24.8,77.7 S142.5,359.9,125.6,353.9z M425.9,461.5c0,0-6.1,14.5-19.6,15.4c-5.8,0.4-10.3-1.2-10.3-1.2s-0.3-0.1-5.3-2.1l-112.9-55 c0,0-10.9-5.7-12.8-15.6c-2.2-8.1,2.7-18.1,2.7-18.1L322,273c0,0,4.8-9.7,12.2-13c0.6-0.3,2.3-1,4.5-1.5c8.1-2.1,18,2.8,18,2.8 l110.7,53.7c0,0,12.6,5.7,15.3,16.2c1.9,7.4-0.5,14-1.8,17.2C474.6,363.8,425.9,461.5,425.9,461.5z"></path> <path style="fill:#609926" d="M326.8,380.1c-8.2,0.1-15.4,5.8-17.3,13.8c-1.9,8,2,16.3,9.1,20c7.7,4,17.5,1.8,22.7-5.4 c5.1-7.1,4.3-16.9-1.8-23.1l24-49.1c1.5,0.1,3.7,0.2,6.2-0.5c4.1-0.9,7.1-3.6,7.1-3.6c4.2,1.8,8.6,3.8,13.2,6.1 c4.8,2.4,9.3,4.9,13.4,7.3c0.9,0.5,1.8,1.1,2.8,1.9c1.6,1.3,3.4,3.1,4.7,5.5c1.9,5.5-1.9,14.9-1.9,14.9 c-2.3,7.6-18.4,40.6-18.4,40.6c-8.1-0.2-15.3,5-17.7,12.5c-2.6,8.1,1.1,17.3,8.9,21.3c7.8,4,17.4,1.7,22.5-5.3 c5-6.8,4.6-16.3-1.1-22.6c1.9-3.7,3.7-7.4,5.6-11.3c5-10.4,13.5-30.4,13.5-30.4c0.9-1.7,5.7-10.3,2.7-21.3 c-2.5-11.4-12.6-16.7-12.6-16.7c-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3c4.7-9.7,9.4-19.3,14.1-29 c-4.1-2-8.1-4-12.2-6.1c-4.8,9.8-9.7,19.7-14.5,29.5c-6.7-0.1-12.9,3.5-16.1,9.4c-3.4,6.3-2.7,14.1,1.9,19.8 C343.2,346.5,335,363.3,326.8,380.1z"></path> </g> </g> </g> </svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
3
docs/assets/icons/github.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 0C3.58 0 0 3.58 0 8C0 11.54 2.29 14.53 5.47 15.59C5.87 15.66 6.02 15.42 6.02 15.21C6.02 15.02 6.01 14.39 6.01 13.72C4 14.09 3.48 13.23 3.32 12.78C3.23 12.55 2.84 11.84 2.5 11.65C2.22 11.5 1.82 11.13 2.49 11.12C3.12 11.11 3.57 11.7 3.72 11.94C4.44 13.15 5.59 12.81 6.05 12.6C6.12 12.08 6.33 11.73 6.56 11.53C4.78 11.33 2.92 10.64 2.92 7.58C2.92 6.71 3.23 5.99 3.74 5.43C3.66 5.23 3.38 4.41 3.82 3.31C3.82 3.31 4.49 3.1 6.02 4.13C6.66 3.95 7.34 3.86 8.02 3.86C8.7 3.86 9.38 3.95 10.02 4.13C11.55 3.09 12.22 3.31 12.22 3.31C12.66 4.41 12.38 5.23 12.3 5.43C12.81 5.99 13.12 6.7 13.12 7.58C13.12 10.65 11.25 11.33 9.47 11.53C9.76 11.78 10.01 12.26 10.01 13.01C10.01 14.08 10 14.94 10 15.21C10 15.42 10.15 15.67 10.55 15.59C13.71 14.53 16 11.53 16 8C16 3.58 12.42 0 8 0Z" transform="scale(64)" fill="#1B1F23"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 968 B |
1
docs/assets/icons/gitlab.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill-rule="evenodd"><path d="M32 61.477L43.784 25.2H20.216z" fill="#e24329"/><path d="M32 61.477L20.216 25.2H3.7z" fill="#fc6d26"/><path d="M3.7 25.2L.12 36.23a2.44 2.44 0 0 0 .886 2.728L32 61.477z" fill="#fca326"/><path d="M3.7 25.2h16.515L13.118 3.366c-.365-1.124-1.955-1.124-2.32 0z" fill="#e24329"/><path d="M32 61.477L43.784 25.2H60.3z" fill="#fc6d26"/><path d="M60.3 25.2l3.58 11.02a2.44 2.44 0 0 1-.886 2.728L32 61.477z" fill="#fca326"/><path d="M60.3 25.2H43.784l7.098-21.844c.365-1.124 1.955-1.124 2.32 0z" fill="#e24329"/></svg>
|
||||
|
After Width: | Height: | Size: 601 B |
1
docs/assets/icons/jira.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="64" height="64"><defs><linearGradient id="A" gradientUnits="userSpaceOnUse"><stop offset=".18" stop-color="#0052cc"/><stop offset="1" stop-color="#2684ff"/></linearGradient><linearGradient id="B" x1="42.023" y1="35.232" x2="44.133" y2="33.122" xlink:href="#A"/><linearGradient xlink:href="#A" id="C" x1="41.464" y1="29.159" x2="39.35" y2="31.273"/></defs><g transform="matrix(6.249587 0 0 6.249587 -228.82126 -169.26286)"><path d="M46.568 31.918l-4.834-4.834-4.834 4.834a.406.406 0 0 0 0 .573l4.834 4.834 4.834-4.834a.406.406 0 0 0 0-.573zm-4.834 1.8l-1.514-1.514 1.514-1.514 1.514 1.514z" fill="#2684ff"/><path d="M41.734 30.7a2.549 2.549 0 0 1-.011-3.594L38.4 30.408l1.803 1.803z" fill="url(#C)"/><path d="M43.252 32.2l-1.518 1.518a2.549 2.549 0 0 1 0 3.606l3.32-3.32z" fill="url(#B)"/></g></svg>
|
||||
|
After Width: | Height: | Size: 888 B |
1
docs/assets/icons/local-git.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32"><path d="M31.396 14.575L17.425.604a2.06 2.06 0 0 0-2.914 0l-2.9 2.9 3.68 3.68c.856-.3 1.836-.095 2.518.587a2.45 2.45 0 0 1 .581 2.533l3.547 3.547c.858-.296 1.848-.105 2.533.582a2.45 2.45 0 1 1-3.469 3.468c-.72-.72-.898-1.78-.534-2.667l-3.308-3.308v8.705a2.5 2.5 0 0 1 .65.464 2.45 2.45 0 1 1-3.468 3.468 2.45 2.45 0 0 1 0-3.468c.237-.236.5-.415.803-.535v-8.786c-.292-.12-.566-.297-.803-.535a2.45 2.45 0 0 1-.528-2.681l-3.63-3.628-9.58 9.57a2.06 2.06 0 0 0 0 2.915l13.972 13.97a2.06 2.06 0 0 0 2.914 0L31.396 17.5a2.06 2.06 0 0 0 0-2.915" fill="#f03c2e"/></svg>
|
||||
|
After Width: | Height: | Size: 643 B |
6
docs/assets/icons/slack.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg width="127" height="127" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M27.2 80c0 7.3-5.9 13.2-13.2 13.2C6.7 93.2.8 87.3.8 80c0-7.3 5.9-13.2 13.2-13.2h13.2V80zm6.6 0c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2v33c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V80z" fill="#E01E5A"/>
|
||||
<path d="M47 27c-7.3 0-13.2-5.9-13.2-13.2C33.8 6.5 39.7.6 47 .6c7.3 0 13.2 5.9 13.2 13.2V27H47zm0 6.7c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H13.9C6.6 60.1.7 54.2.7 46.9c0-7.3 5.9-13.2 13.2-13.2H47z" fill="#36C5F0"/>
|
||||
<path d="M99.9 46.9c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H99.9V46.9zm-6.6 0c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V13.8C66.9 6.5 72.8.6 80.1.6c7.3 0 13.2 5.9 13.2 13.2v33.1z" fill="#2EB67D"/>
|
||||
<path d="M80.1 99.8c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V99.8h13.2zm0-6.6c-7.3 0-13.2-5.9-13.2-13.2 0-7.3 5.9-13.2 13.2-13.2h33.1c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H80.1z" fill="#ECB22E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1,019 B |
675
src/azure.rs
Normal file
|
|
@ -0,0 +1,675 @@
|
|||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
env,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
// NOTE: We continue to issue the small number of Azure DevOps Git REST calls we need
|
||||
// directly through `reqwest` instead of depending on the `azure_devops_rust_api`
|
||||
// crate. The SDK does not yet expose stable coverage for wiki repositories or the
|
||||
// preview API surfaces we rely on, while the raw requests keep the binary lean and
|
||||
// let us opt into newer API versions as Microsoft rolls them out.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use globset::{Glob, GlobSet, GlobSetBuilder};
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
use url::{form_urlencoded, Url};
|
||||
|
||||
use crate::{findings_store, git_url::GitUrl};
|
||||
|
||||
const API_VERSION: &str = "7.1-preview.1";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RepoType {
|
||||
All,
|
||||
Source,
|
||||
Fork,
|
||||
}
|
||||
|
||||
impl RepoType {
|
||||
fn allows(self, is_fork: bool) -> bool {
|
||||
match self {
|
||||
RepoType::All => true,
|
||||
RepoType::Source => !is_fork,
|
||||
RepoType::Fork => is_fork,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RepoSpecifiers {
|
||||
pub organization: Vec<String>,
|
||||
pub project: Vec<String>,
|
||||
pub all_projects: bool,
|
||||
pub repo_filter: RepoType,
|
||||
pub exclude_repos: Vec<String>,
|
||||
}
|
||||
|
||||
impl RepoSpecifiers {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.organization.is_empty() && self.project.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ExcludeMatcher {
|
||||
exact: HashSet<String>,
|
||||
globs: Option<GlobSet>,
|
||||
}
|
||||
|
||||
impl ExcludeMatcher {
|
||||
fn matches(&self, name: &str) -> bool {
|
||||
let candidate = name.to_lowercase();
|
||||
if self.exact.contains(&candidate) {
|
||||
return true;
|
||||
}
|
||||
if let Some(globs) = &self.globs {
|
||||
return globs.is_match(&candidate);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.exact.is_empty() && self.globs.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_glob(pattern: &str) -> bool {
|
||||
pattern.contains('*') || pattern.contains('?') || pattern.contains('[')
|
||||
}
|
||||
|
||||
fn encode_segment(segment: &str) -> String {
|
||||
form_urlencoded::byte_serialize(segment.as_bytes()).collect::<String>()
|
||||
}
|
||||
|
||||
fn normalize_repo_identifier(parts: &[String]) -> Option<String> {
|
||||
if parts.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let repo = parts.last()?.trim().trim_matches('/');
|
||||
let project = parts.get(parts.len() - 2)?.trim().trim_matches('/');
|
||||
if repo.is_empty() || project.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let owner_segments = &parts[..parts.len() - 2];
|
||||
let mut normalized: Vec<String> =
|
||||
owner_segments.iter().map(|s| s.trim().trim_matches('/').to_lowercase()).collect();
|
||||
normalized.retain(|s| !s.is_empty());
|
||||
normalized.push(project.to_lowercase());
|
||||
normalized.push(repo.trim_end_matches(".git").to_lowercase());
|
||||
if normalized.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(normalized.join("/"))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_repo_identifier_from_path(path: &str) -> Option<String> {
|
||||
let segments: Vec<String> = path
|
||||
.trim_matches('/')
|
||||
.split('/')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
if segments.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if segments.len() == 2 {
|
||||
let org = segments.first()?.trim().trim_matches('/');
|
||||
let project = segments.last()?.trim().trim_matches('/');
|
||||
if org.is_empty() || project.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let org = org.to_lowercase();
|
||||
let project_raw = project.to_string();
|
||||
if looks_like_glob(&project_raw) {
|
||||
let pattern = format!("{org}/{}/**", project_raw.to_lowercase());
|
||||
return Some(pattern);
|
||||
}
|
||||
|
||||
let project_normalized = project_raw.trim_end_matches(".git").to_lowercase();
|
||||
let repo = project_normalized.clone();
|
||||
return Some(format!("{org}/{project_normalized}/{repo}"));
|
||||
}
|
||||
|
||||
if segments.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Case 1: Azure URL-style with "_git" marker: .../<project>/_git/<repo>
|
||||
if segments[segments.len().saturating_sub(2)] == "_git" {
|
||||
let mut trimmed = segments.clone();
|
||||
let repo = trimmed.pop()?; // <repo>
|
||||
trimmed.pop()?; // drop "_git"
|
||||
trimmed.push(repo); // .../<project>/<repo>
|
||||
return normalize_repo_identifier(&trimmed);
|
||||
}
|
||||
|
||||
// Case 2: Simple path (and glob-friendly): .../<project>/<repo>
|
||||
// Accept as-is so things like "org/*/repo" work.
|
||||
normalize_repo_identifier(&segments)
|
||||
}
|
||||
|
||||
fn parse_repo_identifier_from_url(remote_url: &str) -> Option<String> {
|
||||
let url = Url::parse(remote_url).ok()?;
|
||||
if let Some(path) = url.path_segments() {
|
||||
let segments: Vec<String> =
|
||||
path.filter(|segment| !segment.is_empty()).map(|segment| segment.to_string()).collect();
|
||||
if segments.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let mut trimmed = segments.clone();
|
||||
let repo = trimmed.pop()?;
|
||||
let marker = trimmed.pop()?;
|
||||
if marker != "_git" {
|
||||
return None;
|
||||
}
|
||||
trimmed.push(repo);
|
||||
normalize_repo_identifier(&trimmed)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_excluded_repo(raw: &str) -> Option<String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(name) = parse_repo_identifier_from_url(trimmed) {
|
||||
return Some(name);
|
||||
}
|
||||
|
||||
if let Some(idx) = trimmed.rfind(':') {
|
||||
if let Some(name) = parse_repo_identifier_from_path(&trimmed[idx + 1..]) {
|
||||
return Some(name);
|
||||
}
|
||||
}
|
||||
|
||||
parse_repo_identifier_from_path(trimmed)
|
||||
}
|
||||
|
||||
fn build_exclude_matcher(exclude_repos: &[String]) -> ExcludeMatcher {
|
||||
let mut exact = HashSet::new();
|
||||
let mut glob_builder = GlobSetBuilder::new();
|
||||
let mut has_glob = false;
|
||||
|
||||
for raw in exclude_repos {
|
||||
match parse_excluded_repo(raw) {
|
||||
Some(name) => {
|
||||
let normalized = name.to_lowercase();
|
||||
if looks_like_glob(&normalized) {
|
||||
match Glob::new(&normalized) {
|
||||
Ok(glob) => {
|
||||
glob_builder.add(glob);
|
||||
has_glob = true;
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Ignoring invalid Azure exclusion pattern '{raw}': {err}");
|
||||
exact.insert(normalized);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
exact.insert(normalized);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
warn!("Ignoring invalid Azure exclusion '{raw}' (expected organization/project[/repository])");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let globs = if has_glob {
|
||||
match glob_builder.build() {
|
||||
Ok(set) => Some(set),
|
||||
Err(err) => {
|
||||
warn!("Failed to build Azure exclusion patterns: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
ExcludeMatcher { exact, globs }
|
||||
}
|
||||
|
||||
fn should_exclude_repo(repo_url: &str, excludes: &ExcludeMatcher) -> bool {
|
||||
if excludes.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if let Some(name) = parse_repo_identifier_from_url(repo_url) {
|
||||
return excludes.matches(&name);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct AzureRepository {
|
||||
#[serde(rename = "remoteUrl")]
|
||||
remote_url: Option<String>,
|
||||
#[serde(rename = "webUrl")]
|
||||
web_url: Option<String>,
|
||||
#[serde(rename = "isFork", default)]
|
||||
is_fork: bool,
|
||||
#[serde(default)]
|
||||
project: AzureProjectRef,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct AzureProjectRef {
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct AzureListResponse<T> {
|
||||
value: Vec<T>,
|
||||
}
|
||||
|
||||
struct AzureAuth {
|
||||
username: Option<String>,
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
impl AzureAuth {
|
||||
fn from_environment() -> Self {
|
||||
let token = env::var("KF_AZURE_TOKEN").or_else(|_| env::var("KF_AZURE_PAT")).ok();
|
||||
let username = env::var("KF_AZURE_USERNAME").ok();
|
||||
Self { username, token }
|
||||
}
|
||||
|
||||
fn apply(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
if let Some(token) = &self.token {
|
||||
let username = self.username.as_deref().unwrap_or("pat");
|
||||
request.basic_auth(username, Some(token))
|
||||
} else {
|
||||
request
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_remote_url(raw: &str) -> Option<String> {
|
||||
let mut url = Url::parse(raw).ok()?;
|
||||
if !url.username().is_empty() {
|
||||
url.set_username("").ok()?;
|
||||
}
|
||||
if url.password().is_some() {
|
||||
url.set_password(None).ok()?;
|
||||
}
|
||||
Some(url.to_string())
|
||||
}
|
||||
|
||||
async fn fetch_repositories_for_org(
|
||||
client: &reqwest::Client,
|
||||
base_url: &Url,
|
||||
organization: &str,
|
||||
auth: &AzureAuth,
|
||||
) -> Result<Vec<AzureRepository>> {
|
||||
let base = base_url.as_str().trim_end_matches('/');
|
||||
let encoded_org = encode_segment(organization);
|
||||
let url = format!("{base}/{encoded_org}/_apis/git/repositories?api-version={API_VERSION}");
|
||||
let request = auth.apply(client.get(&url));
|
||||
let response = request.send().await?;
|
||||
let status = response.status();
|
||||
let headers = response.headers().clone();
|
||||
let body_bytes = response.bytes().await?;
|
||||
|
||||
if !status.is_success() {
|
||||
let body = String::from_utf8_lossy(&body_bytes).trim().to_string();
|
||||
let auth_hint = if matches!(
|
||||
status,
|
||||
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
|
||||
) {
|
||||
if auth.token.is_some() {
|
||||
"Verify that the Azure token or PAT has access to the requested organization and has not expired."
|
||||
} else {
|
||||
"Set KF_AZURE_TOKEN or KF_AZURE_PAT with an Azure DevOps Personal Access Token that can read repositories."
|
||||
}
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
let mut message = format!(
|
||||
"Azure Repos API request failed for organization '{organization}' ({status}): {body}"
|
||||
);
|
||||
if !auth_hint.is_empty() {
|
||||
message.push_str(&format!("\n{auth_hint}"));
|
||||
}
|
||||
return Err(anyhow!(message));
|
||||
}
|
||||
|
||||
let is_json = headers
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| {
|
||||
value.split(';').next().unwrap_or("").trim().eq_ignore_ascii_case("application/json")
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_json {
|
||||
let body = String::from_utf8_lossy(&body_bytes);
|
||||
return Err(anyhow!(
|
||||
"Azure Repos API response for organization '{organization}' did not include JSON: {body}"
|
||||
));
|
||||
}
|
||||
|
||||
let payload: AzureListResponse<AzureRepository> = serde_json::from_slice(&body_bytes)?;
|
||||
Ok(payload.value)
|
||||
}
|
||||
|
||||
fn parse_project_specifiers(projects: &[String]) -> HashMap<String, HashSet<String>> {
|
||||
let mut map: HashMap<String, HashSet<String>> = HashMap::new();
|
||||
for raw in projects {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let parts: Vec<&str> = trimmed.split('/').filter(|segment| !segment.is_empty()).collect();
|
||||
if parts.len() < 2 {
|
||||
warn!(
|
||||
"Ignoring Azure project specifier '{raw}' (expected format ORGANIZATION/PROJECT)"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let project = parts.last().unwrap().to_lowercase();
|
||||
let organization = parts[..parts.len() - 1].join("/").to_lowercase();
|
||||
map.entry(organization).or_default().insert(project);
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
fn canonicalize_organizations(spec: &RepoSpecifiers) -> HashMap<String, String> {
|
||||
let mut org_lookup: HashMap<String, String> = HashMap::new();
|
||||
for org in &spec.organization {
|
||||
let key = org.to_lowercase();
|
||||
org_lookup.entry(key).or_insert_with(|| org.clone());
|
||||
}
|
||||
let project_map = parse_project_specifiers(&spec.project);
|
||||
for (org_lower, _projects) in project_map {
|
||||
org_lookup.entry(org_lower.clone()).or_insert(org_lower);
|
||||
}
|
||||
org_lookup
|
||||
}
|
||||
|
||||
pub async fn enumerate_repo_urls(
|
||||
repo_specifiers: &RepoSpecifiers,
|
||||
base_url: Url,
|
||||
ignore_certs: bool,
|
||||
mut progress: Option<&mut ProgressBar>,
|
||||
) -> Result<Vec<String>> {
|
||||
let auth = AzureAuth::from_environment();
|
||||
let client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(ignore_certs)
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()?;
|
||||
|
||||
let exclude_matcher = build_exclude_matcher(&repo_specifiers.exclude_repos);
|
||||
let project_filters = parse_project_specifiers(&repo_specifiers.project);
|
||||
let has_project_filters = !project_filters.is_empty();
|
||||
|
||||
let org_lookup = canonicalize_organizations(repo_specifiers);
|
||||
if org_lookup.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut organizations: Vec<String> = org_lookup.values().cloned().collect();
|
||||
organizations.sort();
|
||||
organizations.dedup();
|
||||
|
||||
let mut repo_urls = Vec::new();
|
||||
|
||||
for org in organizations {
|
||||
if let Some(pb) = &mut progress {
|
||||
pb.set_message(format!("Fetching Azure repositories for {org}..."));
|
||||
}
|
||||
let repos =
|
||||
fetch_repositories_for_org(&client, &base_url, &org, &auth).await.with_context(
|
||||
|| format!("Failed to fetch repositories for Azure organization '{org}'"),
|
||||
)?;
|
||||
|
||||
let org_key = org.to_lowercase();
|
||||
let project_filter = project_filters.get(&org_key);
|
||||
|
||||
for repo in repos {
|
||||
if !repo_specifiers.repo_filter.allows(repo.is_fork) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let project_name = repo
|
||||
.project
|
||||
.name
|
||||
.as_deref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("");
|
||||
|
||||
if !repo_specifiers.all_projects {
|
||||
if let Some(filters) = project_filter {
|
||||
if project_name.is_empty() || !filters.contains(&project_name.to_lowercase()) {
|
||||
continue;
|
||||
}
|
||||
} else if has_project_filters
|
||||
&& !repo_specifiers
|
||||
.organization
|
||||
.iter()
|
||||
.any(|candidate| candidate.eq_ignore_ascii_case(&org))
|
||||
{
|
||||
// Organization derived solely from project filters without an explicit match
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let remote = repo
|
||||
.remote_url
|
||||
.as_deref()
|
||||
.or(repo.web_url.as_deref())
|
||||
.ok_or_else(|| anyhow!("Missing remote URL for Azure repository"))?;
|
||||
let sanitized = match sanitize_remote_url(remote) {
|
||||
Some(url) => url,
|
||||
None => {
|
||||
warn!("Skipping Azure repository with unparsable URL: {remote}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if should_exclude_repo(&sanitized, &exclude_matcher) {
|
||||
continue;
|
||||
}
|
||||
repo_urls.push(sanitized);
|
||||
}
|
||||
}
|
||||
|
||||
repo_urls.sort();
|
||||
repo_urls.dedup();
|
||||
Ok(repo_urls)
|
||||
}
|
||||
|
||||
pub async fn list_repositories(
|
||||
base_url: Url,
|
||||
ignore_certs: bool,
|
||||
progress_enabled: bool,
|
||||
organizations: &[String],
|
||||
projects: &[String],
|
||||
all_projects: bool,
|
||||
exclude_repos: &[String],
|
||||
repo_filter: RepoType,
|
||||
) -> Result<()> {
|
||||
let repo_specifiers = RepoSpecifiers {
|
||||
organization: organizations.to_vec(),
|
||||
project: projects.to_vec(),
|
||||
all_projects,
|
||||
repo_filter,
|
||||
exclude_repos: exclude_repos.to_vec(),
|
||||
};
|
||||
|
||||
if repo_specifiers.is_empty() {
|
||||
anyhow::bail!("Provide at least one --organization or --project to enumerate Azure Repos");
|
||||
}
|
||||
|
||||
let mut progress = if progress_enabled {
|
||||
let style = ProgressStyle::with_template("{spinner} {msg} [{elapsed_precise}]")
|
||||
.expect("progress bar style template should compile");
|
||||
let pb = ProgressBar::new_spinner()
|
||||
.with_style(style)
|
||||
.with_message("Fetching Azure repositories");
|
||||
pb.enable_steady_tick(Duration::from_millis(500));
|
||||
pb
|
||||
} else {
|
||||
ProgressBar::hidden()
|
||||
};
|
||||
|
||||
let repo_urls =
|
||||
enumerate_repo_urls(&repo_specifiers, base_url, ignore_certs, Some(&mut progress)).await?;
|
||||
|
||||
for url in repo_urls {
|
||||
println!("{}", url);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_repo(repo_url: &GitUrl) -> Option<Url> {
|
||||
Url::parse(repo_url.as_str()).ok()
|
||||
}
|
||||
|
||||
pub fn wiki_url(repo_url: &GitUrl) -> Option<GitUrl> {
|
||||
let url = parse_repo(repo_url)?;
|
||||
let mut segments: Vec<String> = url
|
||||
.path_segments()
|
||||
.map(|segments| segments.filter(|s| !s.is_empty()).map(|s| s.to_string()).collect())
|
||||
.unwrap_or_default();
|
||||
if segments.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let mut repo_name = segments.pop()?;
|
||||
if repo_name.ends_with(".wiki") {
|
||||
return None;
|
||||
}
|
||||
let marker = segments.pop()?;
|
||||
if marker != "_git" {
|
||||
return None;
|
||||
}
|
||||
repo_name.push_str(".wiki");
|
||||
segments.push("_git".to_string());
|
||||
segments.push(repo_name);
|
||||
let mut new_url = url.clone();
|
||||
{
|
||||
let mut path_segments = new_url.path_segments_mut().ok()?;
|
||||
path_segments.clear();
|
||||
for segment in segments {
|
||||
path_segments.push(&segment);
|
||||
}
|
||||
}
|
||||
GitUrl::try_from(new_url).ok()
|
||||
}
|
||||
|
||||
pub async fn fetch_repo_items(
|
||||
_repo_url: &GitUrl,
|
||||
_ignore_certs: bool,
|
||||
_output_root: &Path,
|
||||
_datastore: &Arc<Mutex<findings_store::FindingsStore>>,
|
||||
) -> Result<Vec<PathBuf>> {
|
||||
// Azure DevOps exposes work items and wiki content via additional APIs. For now we
|
||||
// skip fetching extra artifacts and simply return an empty set so callers can rely
|
||||
// on the function existing just like the other git host modules.
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[test]
|
||||
fn sanitize_remote_url_strips_username() {
|
||||
let raw = "https://example@dev.azure.com/example/project/_git/repo";
|
||||
let sanitized = sanitize_remote_url(raw).expect("sanitize");
|
||||
assert_eq!(sanitized, "https://dev.azure.com/example/project/_git/repo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_repo_identifier_from_url_handles_basic_path() {
|
||||
let remote = "https://dev.azure.com/org/project/_git/repo";
|
||||
let ident = parse_repo_identifier_from_url(remote).expect("identifier");
|
||||
assert_eq!(ident, "org/project/repo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_repo_identifier_from_url_handles_nested_org() {
|
||||
let remote = "https://ado.example.com/collection/team/project/_git/repo";
|
||||
let ident = parse_repo_identifier_from_url(remote).expect("identifier");
|
||||
assert_eq!(ident, "collection/team/project/repo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_excluded_repo_accepts_url() {
|
||||
let raw = "https://dev.azure.com/org/project/_git/repo";
|
||||
let ident = parse_excluded_repo(raw).expect("identifier");
|
||||
assert_eq!(ident, "org/project/repo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_excluded_repo_accepts_path() {
|
||||
let raw = "org/project/repo";
|
||||
let ident = parse_excluded_repo(raw).expect("identifier");
|
||||
assert_eq!(ident, "org/project/repo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_excluded_repo_allows_project_alias() {
|
||||
let raw = "Org/Project";
|
||||
let ident = parse_excluded_repo(raw).expect("identifier");
|
||||
assert_eq!(ident, "org/project/project");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_excluded_repo_allows_project_glob() {
|
||||
let raw = "org/*";
|
||||
let ident = parse_excluded_repo(raw).expect("identifier");
|
||||
assert_eq!(ident, "org/*/**");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclude_matcher_matches_glob() {
|
||||
let matcher = build_exclude_matcher(&["org/*/repo".to_string()]);
|
||||
assert!(should_exclude_repo("https://dev.azure.com/org/project/_git/repo", &matcher));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclude_matcher_matches_project_alias() {
|
||||
let matcher = build_exclude_matcher(&["org/project".to_string()]);
|
||||
assert!(should_exclude_repo("https://dev.azure.com/org/project/_git/project", &matcher));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclude_matcher_matches_project_glob() {
|
||||
let matcher = build_exclude_matcher(&["org/*".to_string()]);
|
||||
assert!(should_exclude_repo("https://dev.azure.com/org/project/_git/repo", &matcher));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclude_matcher_is_case_insensitive_for_exact_matches() {
|
||||
let matcher = build_exclude_matcher(&["Org/Project/Repo".to_string()]);
|
||||
assert!(should_exclude_repo("https://dev.azure.com/org/project/_git/repo", &matcher));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclude_matcher_is_case_insensitive_for_globs() {
|
||||
let matcher = build_exclude_matcher(&["ORG/*".to_string()]);
|
||||
assert!(should_exclude_repo("https://dev.azure.com/org/project/_git/repo", &matcher));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiki_url_appends_suffix() {
|
||||
let url = GitUrl::from_str("https://dev.azure.com/org/project/_git/repo").unwrap();
|
||||
let wiki = wiki_url(&url).expect("wiki url");
|
||||
assert_eq!(wiki.as_str(), "https://dev.azure.com/org/project/_git/repo.wiki");
|
||||
}
|
||||
}
|
||||
98
src/cli/commands/azure.rs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
use clap::{Args, Subcommand, ValueEnum, ValueHint};
|
||||
use strum_macros::Display;
|
||||
use url::Url;
|
||||
|
||||
use crate::cli::commands::output::OutputArgs;
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct AzureArgs {
|
||||
#[command(subcommand)]
|
||||
pub command: AzureCommand,
|
||||
|
||||
/// Override Azure DevOps base URL (e.g. for Azure DevOps Server)
|
||||
#[arg(global = true, long, default_value = "https://dev.azure.com/", value_hint = ValueHint::Url)]
|
||||
pub azure_base_url: Url,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum AzureCommand {
|
||||
/// Interact with Azure DevOps repositories
|
||||
#[command(subcommand)]
|
||||
Repos(AzureReposCommand),
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum AzureReposCommand {
|
||||
/// List repositories for organizations or projects
|
||||
List(AzureReposListArgs),
|
||||
}
|
||||
|
||||
#[derive(Args, Debug, Clone)]
|
||||
pub struct AzureReposListArgs {
|
||||
#[command(flatten)]
|
||||
pub repo_specifiers: AzureRepoSpecifiers,
|
||||
|
||||
#[command(flatten)]
|
||||
pub output_args: OutputArgs<AzureOutputFormat>,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug, Clone)]
|
||||
pub struct AzureRepoSpecifiers {
|
||||
/// Repositories belonging to these Azure DevOps organizations or collections
|
||||
#[arg(long = "azure-organization", alias = "organization", value_name = "ORGANIZATION")]
|
||||
pub organization: Vec<String>,
|
||||
|
||||
/// Repositories belonging to the specified Azure DevOps projects (format: ORGANIZATION/PROJECT)
|
||||
#[arg(long = "azure-project", alias = "project", value_name = "ORGANIZATION/PROJECT")]
|
||||
pub project: Vec<String>,
|
||||
|
||||
/// Include repositories from all projects within the specified organizations
|
||||
#[arg(long = "azure-all-projects", alias = "all-azure-projects")]
|
||||
pub all_projects: bool,
|
||||
|
||||
/// Skip repositories when enumerating Azure sources (format: ORGANIZATION/PROJECT/REPOSITORY)
|
||||
#[arg(
|
||||
long = "azure-exclude",
|
||||
alias = "azure-exclude-repo",
|
||||
value_name = "ORGANIZATION/PROJECT/REPOSITORY"
|
||||
)]
|
||||
pub exclude_repos: Vec<String>,
|
||||
|
||||
/// Filter by repository type
|
||||
#[arg(long = "azure-repo-type", default_value_t = AzureRepoType::Source)]
|
||||
pub repo_type: AzureRepoType,
|
||||
}
|
||||
|
||||
impl AzureRepoSpecifiers {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.organization.is_empty() && self.project.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Display, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
|
||||
#[strum(serialize_all = "kebab-case")]
|
||||
pub enum AzureRepoType {
|
||||
Source,
|
||||
Fork,
|
||||
All,
|
||||
}
|
||||
|
||||
impl From<AzureRepoType> for crate::azure::RepoType {
|
||||
fn from(value: AzureRepoType) -> Self {
|
||||
match value {
|
||||
AzureRepoType::Source => crate::azure::RepoType::Source,
|
||||
AzureRepoType::Fork => crate::azure::RepoType::Fork,
|
||||
AzureRepoType::All => crate::azure::RepoType::All,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, ValueEnum, Display)]
|
||||
#[strum(serialize_all = "kebab-case")]
|
||||
pub enum AzureOutputFormat {
|
||||
Pretty,
|
||||
Json,
|
||||
Jsonl,
|
||||
Bson,
|
||||
Sarif,
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ use url::Url;
|
|||
|
||||
use crate::{
|
||||
cli::commands::{
|
||||
azure::AzureRepoType,
|
||||
bitbucket::{BitbucketAuthArgs, BitbucketRepoType},
|
||||
gitea::GiteaRepoType,
|
||||
github::{GitCloneMode, GitHistoryMode, GitHubRepoType},
|
||||
|
|
@ -30,11 +31,14 @@ pub struct InputSpecifierArgs {
|
|||
"bitbucket_user",
|
||||
"bitbucket_workspace",
|
||||
"bitbucket_project",
|
||||
"azure_organization",
|
||||
"azure_project",
|
||||
"git_url",
|
||||
"all_github_organizations",
|
||||
"all_gitlab_groups",
|
||||
"all_gitea_organizations",
|
||||
"all_bitbucket_workspaces",
|
||||
"all_azure_projects",
|
||||
"jira_url",
|
||||
"confluence_url",
|
||||
"docker_image",
|
||||
|
|
@ -176,6 +180,38 @@ pub struct InputSpecifierArgs {
|
|||
#[command(flatten)]
|
||||
pub bitbucket_auth: BitbucketAuthArgs,
|
||||
|
||||
// Azure DevOps Options
|
||||
/// Scan repositories belonging to the specified Azure DevOps organizations or collections
|
||||
#[arg(long = "azure-organization")]
|
||||
pub azure_organization: Vec<String>,
|
||||
|
||||
/// Scan repositories belonging to the specified Azure DevOps projects (format: ORGANIZATION/PROJECT)
|
||||
#[arg(long = "azure-project", value_name = "ORGANIZATION/PROJECT")]
|
||||
pub azure_project: Vec<String>,
|
||||
|
||||
/// Skip repositories when enumerating Azure Repos sources (format: ORGANIZATION/PROJECT/REPOSITORY)
|
||||
#[arg(
|
||||
long = "azure-exclude",
|
||||
alias = "azure-exclude-repo",
|
||||
value_name = "ORGANIZATION/PROJECT/REPOSITORY"
|
||||
)]
|
||||
pub azure_exclude: Vec<String>,
|
||||
|
||||
/// Include repositories from every project within the specified Azure organizations
|
||||
#[arg(long = "all-azure-projects")]
|
||||
pub all_azure_projects: bool,
|
||||
|
||||
/// Use the specified base URL for Azure DevOps (e.g. Azure DevOps Server)
|
||||
#[arg(
|
||||
long = "azure-base-url",
|
||||
default_value = "https://dev.azure.com/",
|
||||
value_hint = ValueHint::Url
|
||||
)]
|
||||
pub azure_base_url: Url,
|
||||
|
||||
#[arg(long = "azure-repo-type", default_value_t = AzureRepoType::Source)]
|
||||
pub azure_repo_type: AzureRepoType,
|
||||
|
||||
/// Jira base URL (e.g. https://jira.example.com)
|
||||
#[arg(long, value_hint = ValueHint::Url, requires = "jql")]
|
||||
pub jira_url: Option<Url>,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod azure;
|
||||
pub mod bitbucket;
|
||||
pub mod gitea;
|
||||
pub mod github;
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ use sysinfo::{MemoryRefreshKind, RefreshKind, System};
|
|||
use tracing::Level;
|
||||
|
||||
use crate::cli::commands::{
|
||||
bitbucket::BitbucketArgs, gitea::GiteaArgs, github::GitHubArgs, gitlab::GitLabArgs,
|
||||
rules::RulesArgs, scan::ScanArgs,
|
||||
azure::AzureArgs, bitbucket::BitbucketArgs, gitea::GiteaArgs, github::GitHubArgs,
|
||||
gitlab::GitLabArgs, rules::RulesArgs, scan::ScanArgs,
|
||||
};
|
||||
|
||||
#[deny(missing_docs)]
|
||||
|
|
@ -77,6 +77,10 @@ pub enum Command {
|
|||
#[command(name = "bitbucket")]
|
||||
Bitbucket(BitbucketArgs),
|
||||
|
||||
/// Interact with the Azure DevOps API
|
||||
#[command(name = "azure")]
|
||||
Azure(AzureArgs),
|
||||
|
||||
/// Manage rules
|
||||
#[command(alias = "rule")]
|
||||
Rules(RulesArgs),
|
||||
|
|
|
|||
|
|
@ -31,6 +31,15 @@ const GITEA_CREDENTIAL_HELPER: &str = r#"credential.helper=!_gteacreds() {
|
|||
fi
|
||||
}; _gteacreds"#;
|
||||
|
||||
const AZURE_CREDENTIAL_HELPER: &str = r#"credential.helper=!_azcreds() {
|
||||
token="${KF_AZURE_TOKEN:-${KF_AZURE_PAT:-}}";
|
||||
if [ -n "$token" ]; then
|
||||
user="${KF_AZURE_USERNAME:-pat}";
|
||||
echo username="$user";
|
||||
echo password="$token";
|
||||
fi
|
||||
}; _azcreds"#;
|
||||
|
||||
/// Represents errors that can occur when interacting with the `git` CLI.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GitError {
|
||||
|
|
@ -79,9 +88,17 @@ impl Git {
|
|||
matches!(std::env::var("KF_BITBUCKET_OAUTH_TOKEN"), Ok(value) if !value.is_empty());
|
||||
let has_bitbucket_credentials =
|
||||
has_bitbucket_oauth_token || (has_bitbucket_username && has_bitbucket_password);
|
||||
let has_azure_token = ["KF_AZURE_TOKEN", "KF_AZURE_PAT"]
|
||||
.iter()
|
||||
.any(|key| matches!(std::env::var(key), Ok(value) if !value.is_empty()));
|
||||
|
||||
// If credentials are provided via environment variables, clear existing helpers first.
|
||||
if has_github_token || has_gitlab_token || has_gitea_token || has_bitbucket_credentials {
|
||||
if has_github_token
|
||||
|| has_gitlab_token
|
||||
|| has_gitea_token
|
||||
|| has_bitbucket_credentials
|
||||
|| has_azure_token
|
||||
{
|
||||
credentials.push("-c".into());
|
||||
credentials.push(r#"credential.helper="#.into());
|
||||
}
|
||||
|
|
@ -114,6 +131,11 @@ impl Git {
|
|||
credentials.push(BITBUCKET_CREDENTIAL_HELPER.into());
|
||||
}
|
||||
|
||||
if has_azure_token {
|
||||
credentials.push("-c".into());
|
||||
credentials.push(AZURE_CREDENTIAL_HELPER.into());
|
||||
}
|
||||
|
||||
Self { credentials, ignore_certs }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod azure;
|
||||
pub mod baseline;
|
||||
pub mod binary;
|
||||
pub mod bitbucket;
|
||||
|
|
|
|||
28
src/main.rs
|
|
@ -33,7 +33,7 @@ use std::{
|
|||
|
||||
use anyhow::{Context, Result};
|
||||
use kingfisher::{
|
||||
bitbucket,
|
||||
azure, bitbucket,
|
||||
cli::{
|
||||
self,
|
||||
commands::{
|
||||
|
|
@ -71,6 +71,7 @@ use tracing_subscriber::{
|
|||
use url::Url;
|
||||
|
||||
use crate::cli::commands::{
|
||||
azure::{AzureCommand, AzureRepoType, AzureReposCommand},
|
||||
bitbucket::{BitbucketAuthArgs, BitbucketCommand, BitbucketRepoType, BitbucketReposCommand},
|
||||
gitea::{GiteaCommand, GiteaRepoType, GiteaReposCommand},
|
||||
gitlab::{GitLabCommand, GitLabRepoType, GitLabReposCommand},
|
||||
|
|
@ -91,6 +92,7 @@ fn main() -> anyhow::Result<()> {
|
|||
Command::GitLab(_) => num_cpus::get(), // Default for GitLab commands
|
||||
Command::Bitbucket(_) => num_cpus::get(), // Default for Bitbucket commands
|
||||
Command::Gitea(_) => num_cpus::get(), // Default for Gitea commands
|
||||
Command::Azure(_) => num_cpus::get(), // Default for Azure commands
|
||||
Command::Rules(_) => num_cpus::get(), // Default for Rules commands
|
||||
};
|
||||
|
||||
|
|
@ -267,6 +269,23 @@ async fn async_main(args: CommandLineArgs) -> Result<()> {
|
|||
}
|
||||
},
|
||||
},
|
||||
Command::Azure(azure_args) => match azure_args.command {
|
||||
AzureCommand::Repos(repos_command) => match repos_command {
|
||||
AzureReposCommand::List(list_args) => {
|
||||
azure::list_repositories(
|
||||
azure_args.azure_base_url.clone(),
|
||||
global_args.ignore_certs,
|
||||
global_args.use_progress(),
|
||||
&list_args.repo_specifiers.organization,
|
||||
&list_args.repo_specifiers.project,
|
||||
list_args.repo_specifiers.all_projects,
|
||||
&list_args.repo_specifiers.exclude_repos,
|
||||
list_args.repo_specifiers.repo_type.into(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
},
|
||||
},
|
||||
Command::Gitea(gitea_args) => match gitea_args.command {
|
||||
GiteaCommand::Repos(repos_command) => match repos_command {
|
||||
GiteaReposCommand::List(list_args) => {
|
||||
|
|
@ -364,6 +383,13 @@ fn create_default_scan_args() -> cli::commands::scan::ScanArgs {
|
|||
bitbucket_repo_type: BitbucketRepoType::Source,
|
||||
bitbucket_auth: BitbucketAuthArgs::default(),
|
||||
|
||||
azure_organization: Vec::new(),
|
||||
azure_project: Vec::new(),
|
||||
azure_exclude: Vec::new(),
|
||||
all_azure_projects: false,
|
||||
azure_base_url: Url::parse("https://dev.azure.com/").unwrap(),
|
||||
azure_repo_type: AzureRepoType::Source,
|
||||
|
||||
jira_url: None,
|
||||
jql: None,
|
||||
confluence_url: None,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
use std::{
|
||||
borrow::Cow,
|
||||
hash::{Hash, Hasher},
|
||||
str,
|
||||
sync::{Arc, Mutex},
|
||||
|
|
@ -40,6 +39,7 @@ use crate::{
|
|||
const MAX_CHUNK_SIZE: usize = 1 << 30; // 1 GiB per scan segment
|
||||
const CHUNK_OVERLAP: usize = 64 * 1024; // 64 KiB overlap to catch boundary matches
|
||||
const BASE64_SCAN_LIMIT: usize = 64 * 1024 * 1024; // skip expensive Base64 pass on huge blobs
|
||||
const TREE_SITTER_SCAN_LIMIT: usize = 64 * 1024; // only run tree-sitter on blobs ≤64 KiB
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// RawMatch
|
||||
|
|
@ -320,18 +320,22 @@ impl<'a> Matcher<'a> {
|
|||
get_base64_strings(blob.bytes())
|
||||
};
|
||||
|
||||
if self.user_data.raw_matches_scratch.is_empty() && b64_items.is_empty() {
|
||||
let lang_hint = lang.as_deref();
|
||||
let has_raw_matches = !self.user_data.raw_matches_scratch.is_empty();
|
||||
let has_base64_items = !b64_items.is_empty();
|
||||
|
||||
if !has_raw_matches && !has_base64_items && !(no_base64 && lang_hint.is_some()) {
|
||||
return Ok(ScanResult::New(Vec::new()));
|
||||
}
|
||||
|
||||
let rules_db = self.rules_db;
|
||||
let mut seen_matches = FxHashSet::default();
|
||||
let mut previous_matches: FxHashMap<usize, Vec<OffsetSpan>> = FxHashMap::default();
|
||||
let tree_sitter_result = if self.user_data.raw_matches_scratch.is_empty() {
|
||||
None
|
||||
} else {
|
||||
lang.and_then(|lang_str| {
|
||||
get_language_and_queries(&lang_str).and_then(|(language, queries)| {
|
||||
let should_run_tree_sitter = blob.len() <= TREE_SITTER_SCAN_LIMIT
|
||||
&& (has_raw_matches || (no_base64 && lang_hint.is_some()));
|
||||
let tree_sitter_result = if should_run_tree_sitter {
|
||||
lang_hint.and_then(|lang_str| {
|
||||
get_language_and_queries(lang_str).and_then(|(language, queries)| {
|
||||
let checker = Checker { language, rules: queries };
|
||||
match checker.check(&blob.bytes()) {
|
||||
Ok(results) => Some(results),
|
||||
|
|
@ -342,6 +346,8 @@ impl<'a> Matcher<'a> {
|
|||
}
|
||||
})
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Process matches
|
||||
let mut matches = Vec::new();
|
||||
|
|
@ -407,7 +413,7 @@ impl<'a> Matcher<'a> {
|
|||
rule_id_usize,
|
||||
&mut seen_matches,
|
||||
origin,
|
||||
Some(ts_match.clone()),
|
||||
Some(ts_match.as_bytes()),
|
||||
*is_base64_decoded,
|
||||
redact,
|
||||
&filename,
|
||||
|
|
@ -437,7 +443,7 @@ impl<'a> Matcher<'a> {
|
|||
rule_id_usize,
|
||||
&mut seen_matches,
|
||||
origin,
|
||||
Some(item.decoded.clone()),
|
||||
Some(item.decoded.as_bytes()),
|
||||
true,
|
||||
redact,
|
||||
&filename,
|
||||
|
|
@ -540,7 +546,7 @@ fn filter_match<'b>(
|
|||
rule_id: usize,
|
||||
seen_matches: &mut FxHashSet<u64>,
|
||||
_origin: &OriginSet,
|
||||
ts_match: Option<String>,
|
||||
ts_match: Option<&[u8]>,
|
||||
is_base64: bool,
|
||||
redact: bool,
|
||||
filename: &str,
|
||||
|
|
@ -551,12 +557,11 @@ fn filter_match<'b>(
|
|||
|
||||
let initial_len = matches.len();
|
||||
|
||||
// Use Cow to avoid unnecessary copying when ts_match is None
|
||||
let byte_slice: Cow<[u8]> = match ts_match {
|
||||
Some(ts_match_value) => Cow::Owned(ts_match_value.into_bytes()),
|
||||
None => Cow::Borrowed(&blob.bytes()[start..end]),
|
||||
};
|
||||
for captures in re.captures_iter(byte_slice.as_ref()) {
|
||||
let blob_bytes = blob.bytes();
|
||||
let default_slice = &blob_bytes[start..end];
|
||||
let haystack = ts_match.unwrap_or(default_slice);
|
||||
|
||||
for captures in re.captures_iter(haystack) {
|
||||
let full_capture = captures.get(0).unwrap();
|
||||
let matching_input = captures.get(1).unwrap_or(full_capture);
|
||||
let min_entropy = rule.min_entropy();
|
||||
|
|
@ -590,8 +595,7 @@ fn filter_match<'b>(
|
|||
}
|
||||
let only_matching_input =
|
||||
&blob.bytes()[matching_input_offset_span.start..matching_input_offset_span.end];
|
||||
let groups =
|
||||
SerializableCaptures::from_captures(&captures, byte_slice.as_ref(), re, redact);
|
||||
let groups = SerializableCaptures::from_captures(&captures, haystack, re, redact);
|
||||
matches.push(BlobMatch {
|
||||
rule: Arc::clone(&rule),
|
||||
blob_id: blob.id_ref(),
|
||||
|
|
|
|||
|
|
@ -48,6 +48,19 @@ const BITBUCKET_FRAGMENT_ENCODE_SET: &AsciiSet = &CONTROLS
|
|||
.add(b'}')
|
||||
.add(b'|');
|
||||
|
||||
const AZURE_QUERY_ENCODE_SET: &AsciiSet = &CONTROLS
|
||||
.add(b' ')
|
||||
.add(b'"')
|
||||
.add(b'#')
|
||||
.add(b'%')
|
||||
.add(b'<')
|
||||
.add(b'>')
|
||||
.add(b'?')
|
||||
.add(b'`')
|
||||
.add(b'{')
|
||||
.add(b'}')
|
||||
.add(b'|');
|
||||
|
||||
fn build_git_urls(
|
||||
repo_url: &str,
|
||||
commit_id: &str,
|
||||
|
|
@ -94,6 +107,19 @@ fn build_git_urls(
|
|||
commit_url = format!("{base}/commits/{commit_id}");
|
||||
file_url = format!("{base}/commits/{commit_id}#L{anchor}F{line}");
|
||||
}
|
||||
} else if host.eq_ignore_ascii_case("dev.azure.com") || host.ends_with(".visualstudio.com")
|
||||
{
|
||||
let normalized = file_path.replace('\\', "/");
|
||||
let trimmed = normalized.trim_start_matches('/');
|
||||
let encoded_path = utf8_percent_encode(trimmed, AZURE_QUERY_ENCODE_SET).to_string();
|
||||
repository_url = repo_url.to_string();
|
||||
commit_url = format!("{repo_url}/commit/{commit_id}");
|
||||
if line > 0 {
|
||||
file_url =
|
||||
format!("{repo_url}/commit/{commit_id}?path=/{}&line={line}", encoded_path);
|
||||
} else {
|
||||
file_url = format!("{repo_url}/commit/{commit_id}?path=/{}", encoded_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -667,6 +693,7 @@ mod tests {
|
|||
cli::commands::output::OutputArgs,
|
||||
cli::commands::scan::{ConfidenceLevel, ScanArgs},
|
||||
cli::commands::{
|
||||
azure::AzureRepoType,
|
||||
bitbucket::{BitbucketAuthArgs, BitbucketRepoType},
|
||||
gitea::GiteaRepoType,
|
||||
github::{GitCloneMode, GitHistoryMode, GitHubRepoType},
|
||||
|
|
@ -789,6 +816,12 @@ mod tests {
|
|||
bitbucket_api_url: Url::parse("https://api.bitbucket.org/2.0/").unwrap(),
|
||||
bitbucket_repo_type: BitbucketRepoType::Source,
|
||||
bitbucket_auth: BitbucketAuthArgs::default(),
|
||||
azure_organization: Vec::new(),
|
||||
azure_project: Vec::new(),
|
||||
azure_exclude: Vec::new(),
|
||||
all_azure_projects: false,
|
||||
azure_base_url: Url::parse("https://dev.azure.com/").unwrap(),
|
||||
azure_repo_type: AzureRepoType::Source,
|
||||
jira_url: None,
|
||||
jql: None,
|
||||
confluence_url: None,
|
||||
|
|
@ -844,6 +877,28 @@ mod tests {
|
|||
.unwrap();
|
||||
assert_eq!(git_file_path, "path/in/history.txt");
|
||||
}
|
||||
|
||||
use super::build_git_urls;
|
||||
|
||||
#[test]
|
||||
fn azure_commit_links_use_query_paths() {
|
||||
let (repo_url, commit_url, file_url) = build_git_urls(
|
||||
"https://dev.azure.com/org/project/_git/repo",
|
||||
"0123456789abcdef",
|
||||
"dir/file.txt",
|
||||
7,
|
||||
);
|
||||
|
||||
assert_eq!(repo_url, "https://dev.azure.com/org/project/_git/repo");
|
||||
assert_eq!(
|
||||
commit_url,
|
||||
"https://dev.azure.com/org/project/_git/repo/commit/0123456789abcdef"
|
||||
);
|
||||
assert_eq!(
|
||||
file_url,
|
||||
"https://dev.azure.com/org/project/_git/repo/commit/0123456789abcdef?path=/dir/file.txt&line=7"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl From<finding_data::FindingDataEntry> for ReportMatch {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ mod tests {
|
|||
use crate::util::intern;
|
||||
use crate::{
|
||||
blob::BlobId,
|
||||
cli::commands::azure::AzureRepoType,
|
||||
cli::commands::bitbucket::{BitbucketAuthArgs, BitbucketRepoType},
|
||||
cli::commands::gitea::GiteaRepoType,
|
||||
cli::commands::github::GitHubRepoType,
|
||||
|
|
@ -109,6 +110,13 @@ mod tests {
|
|||
bitbucket_api_url: Url::parse("https://api.bitbucket.org/2.0/").unwrap(),
|
||||
bitbucket_repo_type: BitbucketRepoType::Source,
|
||||
bitbucket_auth: BitbucketAuthArgs::default(),
|
||||
// Azure DevOps
|
||||
azure_organization: Vec::new(),
|
||||
azure_project: Vec::new(),
|
||||
azure_exclude: Vec::new(),
|
||||
all_azure_projects: false,
|
||||
azure_base_url: Url::parse("https://dev.azure.com/").unwrap(),
|
||||
azure_repo_type: AzureRepoType::Source,
|
||||
// Jira options
|
||||
jira_url: None,
|
||||
jql: None,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
pub(crate) use docker::save_docker_images;
|
||||
pub(crate) use enumerate::enumerate_filesystem_inputs;
|
||||
pub(crate) use repos::{
|
||||
clone_or_update_git_repos, enumerate_bitbucket_repos, enumerate_github_repos,
|
||||
clone_or_update_git_repos, enumerate_azure_repos, enumerate_bitbucket_repos,
|
||||
enumerate_github_repos,
|
||||
};
|
||||
pub use runner::{load_and_record_rules, run_async_scan, run_scan};
|
||||
pub(crate) use validation::run_secret_validation;
|
||||
|
|
|
|||
|
|
@ -31,7 +31,12 @@ impl<'a> BlobProcessor<'a> {
|
|||
) -> Result<Option<DatastoreMessage>> {
|
||||
let _span = debug_span!("matcher", temp_id = blob.temp_id()).entered();
|
||||
let t1 = Instant::now();
|
||||
let res = self.matcher.scan_blob(&blob, &origin, None, redact, no_dedup, no_base64)?;
|
||||
let language_hint = origin
|
||||
.iter()
|
||||
.find_map(|p| p.blob_path())
|
||||
.and_then(|path| ContentInspector::default().guess_language(path, blob.bytes()));
|
||||
let res =
|
||||
self.matcher.scan_blob(&blob, &origin, language_hint, redact, no_dedup, no_base64)?;
|
||||
let scan_us = t1.elapsed().as_micros();
|
||||
match res {
|
||||
// blob already seen, but with no matches; nothing to do!
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ use url::Url;
|
|||
|
||||
use crate::blob::BlobIdMap;
|
||||
use crate::{
|
||||
bitbucket,
|
||||
azure, bitbucket,
|
||||
blob::BlobMetadata,
|
||||
cli::{
|
||||
commands::{github::GitCloneMode, github::GitHistoryMode, scan},
|
||||
|
|
@ -370,6 +370,69 @@ pub async fn enumerate_bitbucket_repos(
|
|||
Ok(repo_urls)
|
||||
}
|
||||
|
||||
pub async fn enumerate_azure_repos(
|
||||
args: &scan::ScanArgs,
|
||||
global_args: &global::GlobalArgs,
|
||||
) -> Result<Vec<GitUrl>> {
|
||||
let repo_specifiers = azure::RepoSpecifiers {
|
||||
organization: args.input_specifier_args.azure_organization.clone(),
|
||||
project: args.input_specifier_args.azure_project.clone(),
|
||||
all_projects: args.input_specifier_args.all_azure_projects,
|
||||
repo_filter: args.input_specifier_args.azure_repo_type.into(),
|
||||
exclude_repos: args.input_specifier_args.azure_exclude.clone(),
|
||||
};
|
||||
|
||||
let mut repo_urls = args.input_specifier_args.git_url.clone();
|
||||
if !repo_specifiers.is_empty() {
|
||||
let mut progress = if global_args.use_progress() {
|
||||
let style =
|
||||
ProgressStyle::with_template("{spinner} {msg} {human_len} [{elapsed_precise}]")
|
||||
.expect("progress bar style template should compile");
|
||||
let pb = ProgressBar::new_spinner()
|
||||
.with_style(style)
|
||||
.with_message("Enumerating Azure Repos repositories...");
|
||||
pb.enable_steady_tick(Duration::from_millis(500));
|
||||
pb
|
||||
} else {
|
||||
ProgressBar::hidden()
|
||||
};
|
||||
|
||||
let mut num_found: u64 = 0;
|
||||
let base_url = args.input_specifier_args.azure_base_url.clone();
|
||||
let repo_strings = azure::enumerate_repo_urls(
|
||||
&repo_specifiers,
|
||||
base_url,
|
||||
global_args.ignore_certs,
|
||||
Some(&mut progress),
|
||||
)
|
||||
.await
|
||||
.context("Failed to enumerate Azure repositories")?;
|
||||
|
||||
for repo_string in repo_strings {
|
||||
match GitUrl::from_str(&repo_string) {
|
||||
Ok(repo_url) => {
|
||||
repo_urls.push(repo_url);
|
||||
num_found += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
progress.suspend(|| {
|
||||
error!("Failed to parse repo URL from {repo_string}: {e}");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
progress.finish_with_message(format!(
|
||||
"Found {} repositories from Azure Repos",
|
||||
HumanCount(num_found)
|
||||
));
|
||||
}
|
||||
|
||||
repo_urls.sort();
|
||||
repo_urls.dedup();
|
||||
Ok(repo_urls)
|
||||
}
|
||||
|
||||
pub async fn fetch_jira_issues(
|
||||
args: &scan::ScanArgs,
|
||||
global_args: &global::GlobalArgs,
|
||||
|
|
@ -519,6 +582,16 @@ pub async fn fetch_git_host_artifacts(
|
|||
)
|
||||
.await?,
|
||||
);
|
||||
} else if host.contains("dev.azure") || host.contains("visualstudio.com") {
|
||||
dirs.extend(
|
||||
azure::fetch_repo_items(
|
||||
repo_url,
|
||||
global_args.ignore_certs,
|
||||
&output_root,
|
||||
datastore,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(dirs)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use tokio::time::{Duration, Instant};
|
|||
use tracing::{debug, error, error_span, info, trace};
|
||||
|
||||
use crate::{
|
||||
bitbucket,
|
||||
azure, bitbucket,
|
||||
cli::{commands::scan, global},
|
||||
findings_store,
|
||||
findings_store::{FindingsStore, FindingsStoreMessage},
|
||||
|
|
@ -20,8 +20,8 @@ use crate::{
|
|||
rules_database::RulesDatabase,
|
||||
safe_list,
|
||||
scanner::{
|
||||
clone_or_update_git_repos, enumerate_bitbucket_repos, enumerate_filesystem_inputs,
|
||||
enumerate_github_repos,
|
||||
clone_or_update_git_repos, enumerate_azure_repos, enumerate_bitbucket_repos,
|
||||
enumerate_filesystem_inputs, enumerate_github_repos,
|
||||
repos::{
|
||||
enumerate_gitea_repos, enumerate_gitlab_repos, fetch_confluence_pages,
|
||||
fetch_git_host_artifacts, fetch_jira_issues, fetch_s3_objects, fetch_slack_messages,
|
||||
|
|
@ -75,11 +75,13 @@ pub async fn run_async_scan(
|
|||
let gitlab_repo_urls = enumerate_gitlab_repos(args, global_args).await?;
|
||||
let gitea_repo_urls = enumerate_gitea_repos(args, global_args).await?;
|
||||
let bitbucket_repo_urls = enumerate_bitbucket_repos(args, global_args).await?;
|
||||
let azure_repo_urls = enumerate_azure_repos(args, global_args).await?;
|
||||
|
||||
// Combine repository URLs
|
||||
repo_urls.extend(gitlab_repo_urls);
|
||||
repo_urls.extend(gitea_repo_urls);
|
||||
repo_urls.extend(bitbucket_repo_urls);
|
||||
repo_urls.extend(azure_repo_urls);
|
||||
repo_urls.sort();
|
||||
repo_urls.dedup();
|
||||
|
||||
|
|
@ -99,6 +101,9 @@ pub async fn run_async_scan(
|
|||
if let Some(w) = bitbucket::wiki_url(url) {
|
||||
wiki_urls.push(w);
|
||||
}
|
||||
if let Some(w) = azure::wiki_url(url) {
|
||||
wiki_urls.push(w);
|
||||
}
|
||||
}
|
||||
repo_urls.extend(wiki_urls);
|
||||
repo_urls.sort();
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use anyhow::Result;
|
|||
use kingfisher::{
|
||||
cli::{
|
||||
commands::{
|
||||
azure::AzureRepoType,
|
||||
bitbucket::{BitbucketAuthArgs, BitbucketRepoType},
|
||||
gitea::GiteaRepoType,
|
||||
github::{GitCloneMode, GitHistoryMode, GitHubRepoType},
|
||||
|
|
@ -85,6 +86,12 @@ fn run_skiplist(skip_regex: Vec<String>, skip_skipword: Vec<String>) -> Result<u
|
|||
bitbucket_api_url: Url::parse("https://api.bitbucket.org/2.0/").unwrap(),
|
||||
bitbucket_repo_type: BitbucketRepoType::Source,
|
||||
bitbucket_auth: BitbucketAuthArgs::default(),
|
||||
azure_organization: Vec::new(),
|
||||
azure_project: Vec::new(),
|
||||
azure_exclude: Vec::new(),
|
||||
all_azure_projects: false,
|
||||
azure_base_url: Url::parse("https://dev.azure.com/").unwrap(),
|
||||
azure_repo_type: AzureRepoType::Source,
|
||||
jira_url: None,
|
||||
jql: None,
|
||||
confluence_url: None,
|
||||
|
|
|
|||
|
|
@ -60,3 +60,34 @@ fn skips_base64_when_disabled() -> anyhow::Result<()> {
|
|||
dir.close()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Ensure tree-sitter based decoding works even when the standalone base64 scanner is disabled
|
||||
#[test]
|
||||
fn detects_base64_in_code_with_tree_sitter() -> anyhow::Result<()> {
|
||||
let dir = tempdir()?;
|
||||
let file_path = dir.path().join("secret.py");
|
||||
// Base64 for ghp_1wuHFikBKQtCcH3EB2FBUkyn8krXhP2qLqPa
|
||||
let encoded = "Z2hwXzF3dUhGaWtCS1F0Q2NIM0VCMkZCVWt5bjhrclhoUDJxTHFQYQ==";
|
||||
fs::write(&file_path, format!("token = \"{}\"\n", encoded))?;
|
||||
|
||||
Command::cargo_bin("kingfisher")?
|
||||
.args([
|
||||
"scan",
|
||||
dir.path().to_str().unwrap(),
|
||||
"--no-binary",
|
||||
"--confidence=low",
|
||||
"--format",
|
||||
"json",
|
||||
"--no-validate",
|
||||
"--no-update-check",
|
||||
])
|
||||
.assert()
|
||||
.code(200)
|
||||
.stdout(
|
||||
predicate::str::contains("ghp_1wuHFikBKQtCcH3EB2FBUkyn8krXhP2qLqPa")
|
||||
.and(predicate::str::contains("\"encoding\": \"base64\"")),
|
||||
);
|
||||
|
||||
dir.close()?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use anyhow::{Context, Result};
|
|||
use kingfisher::{
|
||||
cli::{
|
||||
commands::{
|
||||
azure::AzureRepoType,
|
||||
bitbucket::{BitbucketAuthArgs, BitbucketRepoType},
|
||||
gitea::GiteaRepoType,
|
||||
github::{GitCloneMode, GitHistoryMode, GitHubRepoType},
|
||||
|
|
@ -83,6 +84,13 @@ fn test_bitbucket_remote_scan() -> Result<()> {
|
|||
bitbucket_repo_type: BitbucketRepoType::Source,
|
||||
bitbucket_auth: BitbucketAuthArgs::default(),
|
||||
|
||||
azure_organization: Vec::new(),
|
||||
azure_project: Vec::new(),
|
||||
azure_exclude: Vec::new(),
|
||||
all_azure_projects: false,
|
||||
azure_base_url: Url::parse("https://dev.azure.com/")?,
|
||||
azure_repo_type: AzureRepoType::Source,
|
||||
|
||||
jira_url: None,
|
||||
jql: None,
|
||||
confluence_url: None,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use anyhow::Result;
|
|||
use kingfisher::{
|
||||
cli::{
|
||||
commands::{
|
||||
azure::AzureRepoType,
|
||||
bitbucket::{BitbucketAuthArgs, BitbucketRepoType},
|
||||
gitea::GiteaRepoType,
|
||||
github::{GitCloneMode, GitHistoryMode, GitHubRepoType},
|
||||
|
|
@ -100,6 +101,13 @@ rules:
|
|||
bitbucket_repo_type: BitbucketRepoType::Source,
|
||||
bitbucket_auth: BitbucketAuthArgs::default(),
|
||||
|
||||
azure_organization: Vec::new(),
|
||||
azure_project: Vec::new(),
|
||||
azure_exclude: Vec::new(),
|
||||
all_azure_projects: false,
|
||||
azure_base_url: Url::parse("https://dev.azure.com/").unwrap(),
|
||||
azure_repo_type: AzureRepoType::Source,
|
||||
|
||||
jira_url: None,
|
||||
jql: None,
|
||||
confluence_url: None,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use anyhow::{Context, Result};
|
|||
use kingfisher::{
|
||||
cli::{
|
||||
commands::{
|
||||
azure::AzureRepoType,
|
||||
bitbucket::{BitbucketAuthArgs, BitbucketRepoType},
|
||||
gitea::GiteaRepoType,
|
||||
github::{GitCloneMode, GitHistoryMode, GitHubRepoType},
|
||||
|
|
@ -87,6 +88,13 @@ fn test_github_remote_scan() -> Result<()> {
|
|||
bitbucket_repo_type: BitbucketRepoType::Source,
|
||||
bitbucket_auth: BitbucketAuthArgs::default(),
|
||||
|
||||
azure_organization: Vec::new(),
|
||||
azure_project: Vec::new(),
|
||||
azure_exclude: Vec::new(),
|
||||
all_azure_projects: false,
|
||||
azure_base_url: Url::parse("https://dev.azure.com/").unwrap(),
|
||||
azure_repo_type: AzureRepoType::Source,
|
||||
|
||||
jira_url: None,
|
||||
jql: None,
|
||||
confluence_url: None,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use anyhow::{Context, Result};
|
|||
use kingfisher::{
|
||||
cli::{
|
||||
commands::{
|
||||
azure::AzureRepoType,
|
||||
bitbucket::{BitbucketAuthArgs, BitbucketRepoType},
|
||||
gitea::GiteaRepoType,
|
||||
github::{GitCloneMode, GitHistoryMode, GitHubRepoType},
|
||||
|
|
@ -86,6 +87,13 @@ fn test_gitlab_remote_scan() -> Result<()> {
|
|||
bitbucket_repo_type: BitbucketRepoType::Source,
|
||||
bitbucket_auth: BitbucketAuthArgs::default(),
|
||||
|
||||
azure_organization: Vec::new(),
|
||||
azure_project: Vec::new(),
|
||||
azure_exclude: Vec::new(),
|
||||
all_azure_projects: false,
|
||||
azure_base_url: Url::parse("https://dev.azure.com/")?,
|
||||
azure_repo_type: AzureRepoType::Source,
|
||||
|
||||
jira_url: None,
|
||||
jql: None,
|
||||
confluence_url: None,
|
||||
|
|
@ -216,6 +224,13 @@ fn test_gitlab_remote_scan_no_history() -> Result<()> {
|
|||
bitbucket_repo_type: BitbucketRepoType::Source,
|
||||
bitbucket_auth: BitbucketAuthArgs::default(),
|
||||
|
||||
azure_organization: Vec::new(),
|
||||
azure_project: Vec::new(),
|
||||
azure_exclude: Vec::new(),
|
||||
all_azure_projects: false,
|
||||
azure_base_url: Url::parse("https://dev.azure.com/")?,
|
||||
azure_repo_type: AzureRepoType::Source,
|
||||
|
||||
jira_url: None,
|
||||
jql: None,
|
||||
confluence_url: None,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use anyhow::Result;
|
|||
use kingfisher::{
|
||||
cli::{
|
||||
commands::{
|
||||
azure::AzureRepoType,
|
||||
bitbucket::{BitbucketAuthArgs, BitbucketRepoType},
|
||||
gitea::GiteaRepoType,
|
||||
github::{GitCloneMode, GitHistoryMode, GitHubRepoType},
|
||||
|
|
@ -68,6 +69,12 @@ async fn test_redact_hashes_finding_values() -> Result<()> {
|
|||
bitbucket_api_url: Url::parse("https://api.bitbucket.org/2.0/").unwrap(),
|
||||
bitbucket_repo_type: BitbucketRepoType::Source,
|
||||
bitbucket_auth: BitbucketAuthArgs::default(),
|
||||
azure_organization: Vec::new(),
|
||||
azure_project: Vec::new(),
|
||||
azure_exclude: Vec::new(),
|
||||
all_azure_projects: false,
|
||||
azure_base_url: Url::parse("https://dev.azure.com/").unwrap(),
|
||||
azure_repo_type: AzureRepoType::Source,
|
||||
jira_url: None,
|
||||
jql: None,
|
||||
confluence_url: None,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use anyhow::Result;
|
|||
use kingfisher::{
|
||||
cli::{
|
||||
commands::{
|
||||
azure::AzureRepoType,
|
||||
bitbucket::{BitbucketAuthArgs, BitbucketRepoType},
|
||||
gitea::GiteaRepoType,
|
||||
github::{GitCloneMode, GitHistoryMode, GitHubRepoType},
|
||||
|
|
@ -75,6 +76,12 @@ impl TestContext {
|
|||
bitbucket_api_url: Url::parse("https://api.bitbucket.org/2.0/").unwrap(),
|
||||
bitbucket_repo_type: BitbucketRepoType::Source,
|
||||
bitbucket_auth: BitbucketAuthArgs::default(),
|
||||
azure_organization: Vec::new(),
|
||||
azure_project: Vec::new(),
|
||||
azure_exclude: Vec::new(),
|
||||
all_azure_projects: false,
|
||||
azure_base_url: Url::parse("https://dev.azure.com/").unwrap(),
|
||||
azure_repo_type: AzureRepoType::Source,
|
||||
jira_url: None,
|
||||
jql: None,
|
||||
confluence_url: None,
|
||||
|
|
@ -191,6 +198,12 @@ async fn test_scan_slack_messages() -> Result<()> {
|
|||
bitbucket_api_url: Url::parse("https://api.bitbucket.org/2.0/").unwrap(),
|
||||
bitbucket_repo_type: BitbucketRepoType::Source,
|
||||
bitbucket_auth: BitbucketAuthArgs::default(),
|
||||
azure_organization: Vec::new(),
|
||||
azure_project: Vec::new(),
|
||||
azure_exclude: Vec::new(),
|
||||
all_azure_projects: false,
|
||||
azure_base_url: Url::parse("https://dev.azure.com/").unwrap(),
|
||||
azure_repo_type: AzureRepoType::Source,
|
||||
jira_url: None,
|
||||
jql: None,
|
||||
confluence_url: None,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use anyhow::Result;
|
|||
use kingfisher::{
|
||||
cli::{
|
||||
commands::{
|
||||
azure::AzureRepoType,
|
||||
bitbucket::{BitbucketAuthArgs, BitbucketRepoType},
|
||||
gitea::GiteaRepoType,
|
||||
github::{GitCloneMode, GitHistoryMode, GitHubRepoType},
|
||||
|
|
@ -143,6 +144,13 @@ async fn test_validation_cache_and_depvars() -> Result<()> {
|
|||
bitbucket_repo_type: BitbucketRepoType::Source,
|
||||
bitbucket_auth: BitbucketAuthArgs::default(),
|
||||
|
||||
azure_organization: Vec::new(),
|
||||
azure_project: Vec::new(),
|
||||
azure_exclude: Vec::new(),
|
||||
all_azure_projects: false,
|
||||
azure_base_url: Url::parse("https://dev.azure.com/").unwrap(),
|
||||
azure_repo_type: AzureRepoType::Source,
|
||||
|
||||
jira_url: None,
|
||||
jql: None,
|
||||
confluence_url: None,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use anyhow::{Context, Result};
|
|||
use kingfisher::{
|
||||
cli::{
|
||||
commands::{
|
||||
azure::AzureRepoType,
|
||||
bitbucket::{BitbucketAuthArgs, BitbucketRepoType},
|
||||
gitea::GiteaRepoType,
|
||||
github::{GitCloneMode, GitHistoryMode, GitHubRepoType},
|
||||
|
|
@ -86,6 +87,13 @@ impl TestContext {
|
|||
bitbucket_repo_type: BitbucketRepoType::Source,
|
||||
bitbucket_auth: BitbucketAuthArgs::default(),
|
||||
|
||||
azure_organization: Vec::new(),
|
||||
azure_project: Vec::new(),
|
||||
azure_exclude: Vec::new(),
|
||||
all_azure_projects: false,
|
||||
azure_base_url: Url::parse("https://dev.azure.com/").unwrap(),
|
||||
azure_repo_type: AzureRepoType::Source,
|
||||
|
||||
jira_url: None,
|
||||
jql: None,
|
||||
confluence_url: None,
|
||||
|
|
@ -189,6 +197,13 @@ impl TestContext {
|
|||
bitbucket_repo_type: BitbucketRepoType::Source,
|
||||
bitbucket_auth: BitbucketAuthArgs::default(),
|
||||
|
||||
azure_organization: Vec::new(),
|
||||
azure_project: Vec::new(),
|
||||
azure_exclude: Vec::new(),
|
||||
all_azure_projects: false,
|
||||
azure_base_url: Url::parse("https://dev.azure.com/").unwrap(),
|
||||
azure_repo_type: AzureRepoType::Source,
|
||||
|
||||
jira_url: None,
|
||||
jql: None,
|
||||
confluence_url: None,
|
||||
|
|
|
|||