Merge pull request #96 from mongodb/development

v1.45.0
This commit is contained in:
Mick Grove 2025-08-21 17:29:15 -07:00 committed by GitHub
commit 24a0898245
31 changed files with 923 additions and 145 deletions

View file

@ -2,6 +2,12 @@
All notable changes to this project will be documented in this file.
## [1.45.0]
- Added `--repo-artifacts` flag to scan repository issues, gists/snippets, and wikis when cloning via `--git-url`
- Added rules for sendbird, mattermost, langchain, notion
- JWT validation hardened to reject alg:none by default (only allowed if explicitly configured), require iss for OIDC/JWKS verification, ensuring "Active Credential" means cryptographically verified and time-valid, not just unexpired
- Updated the Git cloning logic to include all refs and minimize clone output, allowing Kingfisher to analyze pull request and deleted branch history
## [1.44.0]
- Fixed issue with self-update on Linux
- Reverted the change to json and jsonl outputs by rule

View file

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

View file

@ -24,7 +24,8 @@ Kingfisher originated as a fork of Praetorian's Nosey Parker, and is built atop
- **Performance**: multithreaded, Hyperscanpowered scanning built for huge codebases
- **Extensible rules**: hundreds of built-in detectors plus YAML-defined custom rules ([docs/RULES.md](/docs/RULES.md))
- **Multiple targets**:
- **Git history**: local repos or GitHub/GitLab orgs/users
- **Git history**: local repos or GitHub/GitLab orgs/users
- **Repository artifacts**: with `--repo-artifacts`, scan GitHub/GitLab repository artifacts such as issues, pull/merge requests, wikis, snippets, and owner gists in addition to code
- **Docker images**: public or private via `--docker-image`
- **Jira issues**: JQLdriven scans with `--jira-url` and `--jql`
- **Confluence pages**: CQLdriven scans with `--confluence-url` and `--cql`
@ -369,12 +370,21 @@ kingfisher scan --github-organization my-org
### Scan remote GitHub repository
`--git-url` clones the repository and scans its files and history. To also inspect
related server-side data, supply `--repo-artifacts`. This flag pulls down the
repository's issues (including pull requests), wiki, and any public gists owned by
the repository owner and scans them for secrets. Fetching these extras counts
against API rate limits and private artifacts require a `KF_GITHUB_TOKEN`.
```bash
# Scan the repository only
kingfisher scan --git-url https://github.com/org/repo.git
# Optionally provide a GitHub Token
KF_GITHUB_TOKEN="ghp_…" kingfisher scan --git-url https://github.com/org/private_repo.git
# Include issues, wiki, and owner gists
kingfisher scan --git-url https://github.com/org/repo.git --repo-artifacts
# Private repositories or artifacts
KF_GITHUB_TOKEN="ghp_…" kingfisher scan --git-url https://github.com/org/private_repo.git --repo-artifacts
```
---
@ -397,8 +407,20 @@ kingfisher scan --gitlab-user johndoe
### Scan remote GitLab repository by URL
`--git-url` by itself clones the project repository. To include server-side
artifacts owned by the project, add `--repo-artifacts`. Kingfisher will retrieve
the project's issues, wiki, and snippets and scan them for secrets. These extra
requests may take longer and require a `KF_GITLAB_TOKEN` for private projects.
```bash
# Scan the repository only
kingfisher scan --git-url https://gitlab.com/group/project.git
# Include issues, wiki, and snippets
kingfisher scan --git-url https://gitlab.com/group/project.git --repo-artifacts
# Private projects or artifacts
KF_GITLAB_TOKEN="glpat-…" kingfisher scan --git-url https://gitlab.com/group/private_project.git --repo-artifacts
```
### List GitLab repositories

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

@ -0,0 +1,52 @@
rules:
- name: LangSmith Personal Access Token
id: kingfisher.langchain.1
pattern: |
(?xi)
\b
(
lsv2_(?:pt)_[0-9a-f]{32}_[0-9a-f]{10}
)
\b
min_entropy: 4.0
examples:
- "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"
validation:
type: Http
content:
request:
method: GET
url: "https://api.smith.langchain.com/api/v1/api-key/current"
headers:
X-API-Key: "{{ TOKEN }}"
Accept: "application/json"
response_matcher:
- report_response: true
- type: StatusMatch
status: [200]
- type: JsonValid
- name: LangSmith Service Key
id: kingfisher.langchain.2
pattern: |
(?xi)
\b
(
lsv2_sk_[0-9a-f]{32}_[0-9a-f]{10}
)
\b
min_entropy: 4.0
examples:
- "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"
validation:
type: Http
content:
request:
method: GET
url: "https://api.smith.langchain.com/api/v1/orgs/current"
headers:
X-API-Key: "{{ TOKEN }}"
Accept: "application/json"
response_matcher:
- report_response: true
- type: StatusMatch
status: [200]

66
data/rules/mattermost.yml Normal file
View file

@ -0,0 +1,66 @@
rules:
- name: Mattermost URL
id: kingfisher.mattermost.1
pattern: |
(?xi)
\b
mattermost
(?:.|[\n\r]){0,32}?
(
https?:\/\/[a-z0-9.-]+
(?::\d{2,5})?
(?:\/[A-Za-z0-9._~\-\/]*)?
)
\b
confidence: medium
visible: false
min_entropy: 2.0
examples:
- mattermost_url = "https://community.mattermost.com"
- mattermost_url='http://localhost:8065'
- 'mattermost_url: https://intra.example.com/mattermost'
- name: Mattermost Access Token
id: kingfisher.mattermost.2
pattern: |
(?xi)
\b
mattermost
(?:.|[\n\r]){0,32}?
(?:SECRET|PRIVATE|ACCESS|KEY|TOKEN)
(?:.|[\n\r]){0,32}?
\b
(
[A-Z0-9]{26}
)
\b
confidence: medium
min_entropy: 4.0
examples:
- "mattermost_token: abcde12345fghij67890klmno1"
validation:
type: Http
content:
request:
method: GET
# Normalize any captured base that already includes /api/v4
url: >
{%- assign base = MATTERMOST_URL | replace: "/api/v4/", "/" | replace: "/api/v4", "" -%}
{{ base }}/api/v4/users/me
headers:
Authorization: "Bearer {{ TOKEN }}"
Accept: application/json
response_matcher:
- report_response: true
- type: StatusMatch
status: [200]
- type: JsonValid
- type: WordMatch
words: ['"id"', '"username"']
match_all_words: true
depends_on_rule:
- rule_id: "kingfisher.mattermost.1"
variable: MATTERMOST_URL
references:
- https://developers.mattermost.com/api-documentation/
- https://developers.mattermost.com/integrate/faq/

85
data/rules/notion.yml Normal file
View file

@ -0,0 +1,85 @@
rules:
- name: Notion Legacy Token
id: kingfisher.notion.1
pattern: |
(?xi)
notion
(?:.|[\\n\r]){0,32}?
\b
(
secret_[A-Z0-9]{43}
)
\b
min_entropy: 4.0
confidence: medium
examples:
- "notion secret_efky1RtWsI0CB1Sn4TRRBLpemW1V11XwPRX3lzUKc5Q"
validation:
type: Http
content:
request:
headers:
Notion-Version: "2022-06-28"
Authorization: "Bearer {{ TOKEN }}"
method: GET
response_matcher:
- report_response: true
- type: StatusMatch
status: [200]
- type: WordMatch
words:
- '"object":"user"'
- '"type":"bot"'
match_all_words: true
url: https://api.notion.com/v1/users/me
- name: Notion Token
id: kingfisher.notion.2
pattern: |
(?xi)
notion
(?:.|[\\n\r]){0,32}?
\b
(
ntn_[A-Z0-9]{40,55}
)
\b
min_entropy: 4.0
confidence: medium
references:
- https://developers.notion.com/page/changelog#september-11-2024
examples:
- "notion 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"
validation:
type: Http
content:
request:
headers:
Notion-Version: "2022-06-28"
Authorization: "Bearer {{ TOKEN }}"
method: GET
response_matcher:
- report_response: true
- type: StatusMatch
status: [200]
- type: WordMatch
words:
- '"object":"user"'
- '"type":"bot"'
match_all_words: true
url: https://api.notion.com/v1/users/me
- name: Notion OAuth Refresh Token
id: kingfisher.notion.3
pattern: |
(?xi)
notion
(?:.|[\\n\r]){0,32}?
\b
(
nrt_[A-Z0-9_]{40,55}
)
\b
min_entropy: 3.5
confidence: medium
examples:
- "notion refresh token = nrt_4Y29zY29vbF9leGFtcGxlX3JlZnJlc2hfdG9rZW4xMjM0NQ"

57
data/rules/sendbird.yml Normal file
View file

@ -0,0 +1,57 @@
rules:
- name: Sendbird Application ID
id: kingfisher.sendbird.1
pattern: |
(?xi)
sendbird
(?:.|[\\n\r]){0,32}?
(?:APPLICATION|APP_ID|APP|CLIENT|ID)
(?:.|[\n\r]){0,32}?
\b
(
[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}
)
\b
confidence: medium
visible: false
min_entropy: 3.0
examples:
- "sendbird_app_id: 12345678-1234-1234-1234-1234567890ab"
- name: Sendbird API Token
id: kingfisher.sendbird.2
pattern: |
(?xi)
sendbird
(?:.|[\\n\r]){0,32}?
(?:SECRET|PRIVATE|ACCESS|KEY|TOKEN)
(?:.|[\n\r]){0,32}?
\b
(
[a-f0-9]{40}
)
\b
confidence: medium
min_entropy: 4.0
examples:
- "sendbird_api_token: 1234567890abcdef1234567890abcdef12345678"
validation:
type: Http
content:
request:
method: GET
url: "https://api-{{SENDBIRD_APP_ID}}.sendbird.com/v3/users"
headers:
"Api-Token": "{{TOKEN}}"
response_matcher:
- report_response: true
- type: StatusMatch
status: [200]
- type: WordMatch
words:
- '"users":'
depends_on_rule:
- rule_id: "kingfisher.sendbird.1"
variable: SENDBIRD_APP_ID
references:
- https://sendbird.com/docs/chat/platform-api/v3/prepare-to-use-api#2-authentication

View file

@ -1,7 +1,13 @@
rules:
- name: StackHawk API Key
id: kingfisher.stackhawk.1
pattern: '\b(hawk\.[0-9A-Za-z_-]{20}\.[0-9A-Za-z_-]{20})\b'
pattern: |
(?xi)
\b
(
hawk\.[0-9A-Z_-]{20}\.[0-9A-Z_-]{20}
)
\b
confidence: medium
min_entropy: 3.0
examples:

View file

@ -46,10 +46,7 @@ fn normalize_path(p: &Path, roots: &[PathBuf]) -> String {
for root in roots {
if let Ok(stripped) = p.strip_prefix(root) {
if let Some(name) = root.file_name() {
return PathBuf::from(name)
.join(stripped)
.to_string_lossy()
.replace('\\', "/");
return PathBuf::from(name).join(stripped).to_string_lossy().replace('\\', "/");
}
}
}

View file

@ -154,6 +154,10 @@ pub struct InputSpecifierArgs {
#[arg(long, default_value_t = true, action = clap::ArgAction::Set, help_heading = "Git Options")]
pub commit_metadata: bool,
/// Also scan repository host artifacts like issues, wikis, and gists/snippets
#[arg(long, help_heading = "Git Options")]
pub repo_artifacts: bool,
/// Enable or disable scanning nested git repositories
#[arg(long, default_value_t = true)]
pub scan_nested_repos: bool,

View file

@ -2,7 +2,6 @@ use once_cell::sync::Lazy;
use std::path::Path;
use tokei::LanguageType;
// Precompute all (shebang_prefix_bytes, language) pairs once.
// Sort longest-first so more specific shebangs win.
static SHEBANG_PREFIXES: Lazy<Vec<(&'static [u8], LanguageType)>> = Lazy::new(|| {

View file

@ -56,6 +56,7 @@ pub struct FindingsStore {
slack_links: FxHashMap<PathBuf, String>,
confluence_links: FxHashMap<PathBuf, String>,
s3_buckets: FxHashMap<PathBuf, String>,
repo_links: FxHashMap<PathBuf, String>,
}
impl FindingsStore {
pub fn new(clone_dir: PathBuf) -> Self {
@ -77,6 +78,7 @@ impl FindingsStore {
slack_links: FxHashMap::default(),
confluence_links: FxHashMap::default(),
s3_buckets: FxHashMap::default(),
repo_links: FxHashMap::default(),
}
}
@ -318,6 +320,14 @@ impl FindingsStore {
&self.confluence_links
}
pub fn register_repo_link(&mut self, path: PathBuf, link: String) {
self.repo_links.insert(path, link);
}
pub fn repo_links(&self) -> &FxHashMap<PathBuf, String> {
&self.repo_links
}
pub fn register_s3_bucket(&mut self, dir: PathBuf, bucket: String) {
self.s3_buckets.insert(dir, bucket);
}

View file

@ -137,6 +137,9 @@ impl Git {
if let Some(arg) = clone_mode.arg() {
cmd.arg(arg);
}
cmd.arg("--quiet");
cmd.arg("-c");
cmd.arg("remote.origin.fetch=+refs/*:refs/remotes/origin/*");
cmd.arg(repo_url.as_str());
cmd.arg(output_dir);
debug!("{cmd:#?}");

View file

@ -1,4 +1,10 @@
use std::{env, sync::Arc, time::Duration};
use std::{
collections::HashSet,
env, fs,
path::{Path, PathBuf},
sync::{Arc, Mutex},
time::Duration,
};
use anyhow::{Context, Result};
use indicatif::{ProgressBar, ProgressStyle};
@ -7,8 +13,12 @@ use octorust::{
types::{Order, ReposListOrgSort, ReposListOrgType, ReposListUserType},
Client,
};
use serde_json::Value;
use url::Url;
use crate::{findings_store, git_url::GitUrl};
use std::str::FromStr;
#[derive(Debug)]
pub struct RepoSpecifiers {
pub user: Vec<String>,
@ -161,3 +171,190 @@ pub async fn list_repositories(
}
Ok(())
}
fn parse_repo(repo_url: &GitUrl) -> Option<(String, String, String)> {
let url = Url::parse(repo_url.as_str()).ok()?;
let host = url.host_str()?.to_string();
let mut segments = url.path_segments()?;
let owner = segments.next()?.to_string();
let mut repo = segments.next()?.to_string();
if let Some(stripped) = repo.strip_suffix(".git") {
repo = stripped.to_string();
}
Some((host, owner, repo))
}
pub fn wiki_url(repo_url: &GitUrl) -> Option<GitUrl> {
let (host, owner, repo) = parse_repo(repo_url)?;
let wiki = format!("https://{host}/{owner}/{repo}.wiki.git");
GitUrl::from_str(&wiki).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>> {
let (_, owner, repo) = parse_repo(repo_url).context("invalid GitHub repo URL")?;
let client = reqwest::Client::builder().danger_accept_invalid_certs(ignore_certs).build()?;
let mut dirs = Vec::new();
// Issues
let issues_dir = output_root.join("github_issues").join(&owner).join(&repo);
fs::create_dir_all(&issues_dir)?;
let mut page = 1;
loop {
let url = format!(
"https://api.github.com/repos/{owner}/{repo}/issues?state=all&per_page=100&page={page}"
);
let mut req = client.get(&url).header("User-Agent", "kingfisher");
if let Ok(token) = env::var("KF_GITHUB_TOKEN") {
if !token.is_empty() {
req = req.bearer_auth(token);
}
}
let resp = req.send().await?;
if !resp.status().is_success() {
break;
}
let issues: Vec<Value> = resp.json().await?;
if issues.is_empty() {
break;
}
for issue in issues {
let number = issue.get("number").and_then(|v| v.as_u64()).unwrap_or(0);
let title = issue.get("title").and_then(|v| v.as_str()).unwrap_or("");
let body = issue.get("body").and_then(|v| v.as_str()).unwrap_or("");
let content = format!("# {title}\n\n{body}");
let file_path = issues_dir.join(format!("issue_{number}.md"));
fs::write(&file_path, content)?;
let url = format!("https://github.com/{owner}/{repo}/issues/{number}");
let mut ds = datastore.lock().unwrap();
ds.register_repo_link(file_path, url);
}
page += 1;
}
if issues_dir.read_dir().ok().and_then(|mut d| d.next()).is_some() {
dirs.push(issues_dir);
}
// Gists
let gists_dir = output_root.join("github_gists").join(&owner);
fs::create_dir_all(&gists_dir)?;
let mut seen = HashSet::new();
// Public gists for the owner
page = 1;
loop {
let url = format!("https://api.github.com/users/{owner}/gists?per_page=100&page={page}");
let mut req = client.get(&url).header("User-Agent", "kingfisher");
if let Ok(token) = env::var("KF_GITHUB_TOKEN") {
if !token.is_empty() {
req = req.bearer_auth(&token);
}
}
let resp = req.send().await?;
if !resp.status().is_success() {
break;
}
let gists: Vec<Value> = resp.json().await?;
if gists.is_empty() {
break;
}
for gist in gists {
if let Some(id) = gist.get("id").and_then(|v| v.as_str()) {
if seen.insert(id.to_string()) {
let mut req_g = client
.get(&format!("https://api.github.com/gists/{id}"))
.header("User-Agent", "kingfisher");
if let Ok(token) = env::var("KF_GITHUB_TOKEN") {
if !token.is_empty() {
req_g = req_g.bearer_auth(&token);
}
}
let detail: Value = req_g.send().await?.json().await?;
if let Some(files) = detail.get("files").and_then(|v| v.as_object()) {
let gist_dir = gists_dir.join(id);
fs::create_dir_all(&gist_dir)?;
for (fname, fobj) in files {
if let Some(content) = fobj.get("content").and_then(|v| v.as_str()) {
let file_path = gist_dir.join(fname);
fs::write(&file_path, content)?;
let url = format!("https://gist.github.com/{id}");
let mut ds = datastore.lock().unwrap();
ds.register_repo_link(file_path, url);
}
}
}
}
}
}
page += 1;
}
// Private gists for authenticated user if they own the repo
if let Ok(token) = env::var("KF_GITHUB_TOKEN") {
if !token.is_empty() {
page = 1;
loop {
let url = format!("https://api.github.com/gists?per_page=100&page={page}");
let resp = client
.get(&url)
.header("User-Agent", "kingfisher")
.bearer_auth(&token)
.send()
.await?;
if !resp.status().is_success() {
break;
}
let gists: Vec<Value> = resp.json().await?;
if gists.is_empty() {
break;
}
for gist in gists {
let owner_login =
gist.get("owner").and_then(|o| o.get("login")).and_then(|v| v.as_str());
if owner_login == Some(owner.as_str()) {
if let Some(id) = gist.get("id").and_then(|v| v.as_str()) {
if seen.insert(id.to_string()) {
let detail: Value = client
.get(&format!("https://api.github.com/gists/{id}"))
.header("User-Agent", "kingfisher")
.bearer_auth(&token)
.send()
.await?
.json()
.await?;
if let Some(files) = detail.get("files").and_then(|v| v.as_object())
{
let gist_dir = gists_dir.join(id);
fs::create_dir_all(&gist_dir)?;
for (fname, fobj) in files {
if let Some(content) =
fobj.get("content").and_then(|v| v.as_str())
{
let file_path = gist_dir.join(fname);
fs::write(&file_path, content)?;
let url = format!("https://gist.github.com/{id}");
let mut ds = datastore.lock().unwrap();
ds.register_repo_link(file_path, url);
}
}
}
}
}
}
}
page += 1;
}
}
}
if gists_dir.read_dir().ok().and_then(|mut d| d.next()).is_some() {
dirs.push(gists_dir);
}
Ok(dirs)
}

View file

@ -1,4 +1,9 @@
use std::{env, time::Duration};
use std::{
env, fs,
path::{Path, PathBuf},
sync::{Arc, Mutex},
time::Duration,
};
use anyhow::{Context, Result};
use gitlab::{
@ -12,7 +17,11 @@ use gitlab::{
};
use indicatif::{ProgressBar, ProgressStyle};
use serde::Deserialize;
use url::Url;
use serde_json::Value;
use url::{form_urlencoded, Url};
use crate::{findings_store, git_url::GitUrl};
use std::str::FromStr;
#[derive(Deserialize)]
struct SimpleUser {
@ -197,3 +206,117 @@ pub async fn list_repositories(
Ok(())
}
fn parse_repo(repo_url: &GitUrl) -> Option<(String, String)> {
let url = Url::parse(repo_url.as_str()).ok()?;
let host = url.host_str()?.to_string();
let mut path = url.path().trim_start_matches('/').to_string();
if let Some(stripped) = path.strip_suffix(".git") {
path = stripped.to_string();
}
Some((host, path))
}
pub fn wiki_url(repo_url: &GitUrl) -> Option<GitUrl> {
let (host, path) = parse_repo(repo_url)?;
let wiki = format!("https://{host}/{path}.wiki.git");
GitUrl::from_str(&wiki).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>> {
let (host, path) = parse_repo(repo_url).context("invalid GitLab repo URL")?;
let encoded = form_urlencoded::byte_serialize(path.as_bytes()).collect::<String>();
let client = reqwest::Client::builder().danger_accept_invalid_certs(ignore_certs).build()?;
let mut dirs = Vec::new();
// Issues
let issues_dir = output_root.join("gitlab_issues").join(path.replace('/', "_"));
fs::create_dir_all(&issues_dir)?;
let mut page = 1;
loop {
let url = format!(
"https://{host}/api/v4/projects/{encoded}/issues?scope=all&state=all&per_page=100&page={page}"
);
let mut req = client.get(&url);
if let Ok(token) = env::var("KF_GITLAB_TOKEN") {
if !token.is_empty() {
req = req.header("PRIVATE-TOKEN", token);
}
}
let resp = req.send().await?;
if !resp.status().is_success() {
break;
}
let issues: Vec<Value> = resp.json().await?;
if issues.is_empty() {
break;
}
for issue in issues {
let number = issue.get("iid").and_then(|v| v.as_u64()).unwrap_or(0);
let title = issue.get("title").and_then(|v| v.as_str()).unwrap_or("");
let body = issue.get("description").and_then(|v| v.as_str()).unwrap_or("");
let content = format!("# {title}\n\n{body}");
let file_path = issues_dir.join(format!("issue_{number}.md"));
fs::write(&file_path, content)?;
let url = format!("https://{host}/{path}/-/issues/{number}");
let mut ds = datastore.lock().unwrap();
ds.register_repo_link(file_path, url);
}
page += 1;
}
if issues_dir.read_dir().ok().and_then(|mut d| d.next()).is_some() {
dirs.push(issues_dir);
}
// Snippets
let snippets_dir = output_root.join("gitlab_snippets").join(path.replace('/', "_"));
fs::create_dir_all(&snippets_dir)?;
page = 1;
loop {
let url =
format!("https://{host}/api/v4/projects/{encoded}/snippets?per_page=100&page={page}");
let mut req = client.get(&url);
if let Ok(token) = env::var("KF_GITLAB_TOKEN") {
if !token.is_empty() {
req = req.header("PRIVATE-TOKEN", token);
}
}
let resp = req.send().await?;
if !resp.status().is_success() {
break;
}
let snippets: Vec<Value> = resp.json().await?;
if snippets.is_empty() {
break;
}
for snip in snippets {
if let Some(id) = snip.get("id").and_then(|v| v.as_u64()) {
let raw_url = format!("https://{host}/api/v4/projects/{encoded}/snippets/{id}/raw");
let mut req_s = client.get(&raw_url);
if let Ok(token) = env::var("KF_GITLAB_TOKEN") {
if !token.is_empty() {
req_s = req_s.header("PRIVATE-TOKEN", token);
}
}
let raw = req_s.send().await?.text().await?;
let file_path = snippets_dir.join(format!("snippet_{id}"));
fs::write(&file_path, raw)?;
let url = format!("https://{host}/{path}/-/snippets/{id}");
let mut ds = datastore.lock().unwrap();
ds.register_repo_link(file_path, url);
}
}
page += 1;
}
if snippets_dir.read_dir().ok().and_then(|mut d| d.next()).is_some() {
dirs.push(snippets_dir);
}
Ok(dirs)
}

View file

@ -305,8 +305,9 @@ fn create_default_scan_args() -> cli::commands::scan::ScanArgs {
// git clone / history options
git_clone: GitCloneMode::Bare,
git_history: GitHistoryMode::Full,
scan_nested_repos: true,
commit_metadata: true,
repo_artifacts: false,
scan_nested_repos: true,
},
content_filtering_args: ContentFilteringArgs {
max_file_size_mb: 25.0,

View file

@ -148,6 +148,11 @@ impl DetailsReporter {
ds.slack_links().get(path).cloned()
}
fn repo_artifact_url(&self, path: &std::path::Path) -> Option<String> {
let ds = self.datastore.lock().ok()?;
ds.repo_links().get(path).cloned()
}
fn s3_display_path(&self, path: &std::path::Path) -> Option<String> {
let ds = self.datastore.lock().ok()?;
for (dir, bucket) in ds.s3_buckets().iter() {
@ -338,7 +343,9 @@ impl DetailsReporter {
.iter()
.find_map(|origin| match origin {
Origin::File(e) => {
if let Some(url) = self.jira_issue_url(&e.path, args) {
if let Some(url) = self.repo_artifact_url(&e.path) {
Some(url)
} else if let Some(url) = self.jira_issue_url(&e.path, args) {
Some(url)
} else if let Some(url) = self.confluence_page_url(&e.path) {
Some(url)

View file

@ -105,8 +105,9 @@ mod tests {
// clone / history options
git_clone: GitCloneMode::Bare,
git_history: GitHistoryMode::Full,
scan_nested_repos: true,
commit_metadata: true,
repo_artifacts: false,
scan_nested_repos: true,
},
content_filtering_args: ContentFilteringArgs {
max_file_size_mb: 25.0,

View file

@ -7,6 +7,7 @@ use anyhow::{Context, Result};
use indicatif::{HumanCount, ProgressBar, ProgressStyle};
use tokio::time::Duration;
use tracing::{debug, error, info};
use url::Url;
use crate::blob::BlobIdMap;
use crate::{
@ -102,7 +103,12 @@ pub fn clone_or_update_git_repos(
progress.suspend(|| info!("Cloning {repo_url}..."));
if let Err(e) = git.create_fresh_clone(repo_url, &output_dir, clone_mode) {
progress.suspend(|| {
error!("Failed to clone {repo_url} to {}: {e}", output_dir.display());
if repo_url.as_str().ends_with(".wiki.git") {
info!("Wiki repository not found for {repo_url}, skipping");
debug!("Failed to clone {repo_url} to {}: {e}", output_dir.display());
} else {
error!("Failed to clone {repo_url} to {}: {e}", output_dir.display());
}
debug!("Skipping scan of {repo_url}");
});
progress.inc(1);
@ -328,6 +334,46 @@ pub async fn fetch_slack_messages(
Ok(vec![output_dir])
}
pub async fn fetch_git_host_artifacts(
repo_urls: &[GitUrl],
global_args: &global::GlobalArgs,
datastore: &Arc<Mutex<findings_store::FindingsStore>>,
) -> Result<Vec<PathBuf>> {
let output_root = {
let ds = datastore.lock().unwrap();
ds.clone_root()
};
let mut dirs = Vec::new();
for repo_url in repo_urls {
let host = Url::parse(repo_url.as_str())
.ok()
.and_then(|u| u.host_str().map(|s| s.to_string()))
.unwrap_or_default();
if host.contains("github") {
dirs.extend(
github::fetch_repo_items(
repo_url,
global_args.ignore_certs,
&output_root,
datastore,
)
.await?,
);
} else if host.contains("gitlab") {
dirs.extend(
gitlab::fetch_repo_items(
repo_url,
global_args.ignore_certs,
&output_root,
datastore,
)
.await?,
);
}
}
Ok(dirs)
}
pub async fn fetch_s3_objects(
args: &scan::ScanArgs,
datastore: &Arc<Mutex<findings_store::FindingsStore>>,

View file

@ -10,6 +10,7 @@ use crate::{
cli::{commands::scan, global},
findings_store,
findings_store::{FindingsStore, FindingsStoreMessage},
github, gitlab,
liquid_filters::register_all,
matcher::MatcherStats,
reporter::styles::Styles,
@ -20,8 +21,8 @@ use crate::{
scanner::{
clone_or_update_git_repos, enumerate_filesystem_inputs, enumerate_github_repos,
repos::{
enumerate_gitlab_repos, fetch_confluence_pages, fetch_jira_issues, fetch_s3_objects,
fetch_slack_messages,
enumerate_gitlab_repos, fetch_confluence_pages, fetch_git_host_artifacts,
fetch_jira_issues, fetch_s3_objects, fetch_slack_messages,
},
run_secret_validation, save_docker_images,
summary::print_scan_summary,
@ -76,7 +77,30 @@ pub async fn run_async_scan(
repo_urls.sort();
repo_urls.dedup();
// Add wiki repositories for each URL when requested
if args.input_specifier_args.repo_artifacts {
let mut wiki_urls = Vec::new();
for url in &repo_urls {
if let Some(w) = github::wiki_url(url) {
wiki_urls.push(w);
}
if let Some(w) = gitlab::wiki_url(url) {
wiki_urls.push(w);
}
}
repo_urls.extend(wiki_urls);
repo_urls.sort();
repo_urls.dedup();
}
let mut input_roots = clone_or_update_git_repos(args, global_args, &repo_urls, &datastore)?;
// Fetch issues, gists, and wikis if enabled
if args.input_specifier_args.repo_artifacts {
let repo_artifact_dirs =
fetch_git_host_artifacts(&repo_urls, global_args, &datastore).await?;
input_roots.extend(repo_artifact_dirs);
}
// Fetch Jira issues if requested
let jira_dirs = fetch_jira_issues(args, global_args, &datastore).await?;
input_roots.extend(jira_dirs);

View file

@ -56,7 +56,7 @@ pub fn check_for_update(global_args: &GlobalArgs, base_url: Option<&str>) -> Opt
info!(
"{}",
styles.style_finding_active_heading.apply_to(
"Homebrew install detected will notify about updates but not selfupdate"
"Homebrew install detected - will notify about updates but not self-update"
)
);
}
@ -105,7 +105,7 @@ pub fn check_for_update(global_args: &GlobalArgs, base_url: Option<&str>) -> Opt
// Linux releases also ship as .deb and .rpm packages; select the .tgz asset for selfupdates
#[cfg(not(target_os = "windows"))]
builder.identifier("tgz");
// Build the updater.
let Ok(updater) = builder.build() else {
warn!("Failed to configure update checker");
@ -158,9 +158,9 @@ pub fn check_for_update(global_args: &GlobalArgs, base_url: Option<&str>) -> Opt
warn!(
"{}",
styles.style_finding_active_heading.apply_to(
"Cannot replace the current binary permission denied.\n\
"Cannot replace the current binary - permission denied.\n\
If you installed via a package manager, run its upgrade command.\n\
Otherwise reinstall to a userwritable directory or rerun with sudo."
Otherwise reinstall to a user-writable directory or re-run with sudo."
)
);
}

View file

@ -46,8 +46,33 @@ struct Claims {
aud: Option<Aud>,
}
/// Runtime options for JWT validation policy.
#[derive(Clone, Default)]
pub struct ValidateOptions {
/// If true, accept unsigned tokens (`alg: "none"`) as long as temporal checks pass.
/// Default is **false** (more secure).
pub allow_alg_none: bool,
/// If provided and `iss` is absent, use this key to cryptographically verify the token.
/// Useful for non-OIDC flows where you already know the verification key.
pub fallback_decoding_key: Option<DecodingKey>,
}
/// Backwards-compatible entry point with secure defaults:
/// - `alg: none` is **rejected**
/// - `iss` is **required** unless `fallback_decoding_key` is supplied (not supplied here)
pub async fn validate_jwt(token: &str) -> Result<(bool, String)> {
// --- insecure payload decode -------------------------------------------------
validate_jwt_with(
token,
&ValidateOptions { allow_alg_none: false, fallback_decoding_key: None },
)
.await
}
/// Strict validator with policy control.
/// Returns (is_active_credential, explanation).
pub async fn validate_jwt_with(token: &str, opts: &ValidateOptions) -> Result<(bool, String)> {
// --- insecure payload decode to read claims --------------------------------
let claims: Claims = {
let payload_b64 = token.split('.').nth(1).ok_or_else(|| anyhow!("invalid JWT format"))?;
let payload_json = URL_SAFE_NO_PAD
@ -69,132 +94,149 @@ pub async fn validate_jwt(token: &str) -> Result<(bool, String)> {
}
}
// parse header enough to read "alg" without jsonwebtoken's enum (which rejects "none")
let header_b64 = token.split('.').next().ok_or_else(|| anyhow!("invalid JWT format"))?;
let header_json =
URL_SAFE_NO_PAD.decode(header_b64).map_err(|e| anyhow!("invalid base64 in header: {e}"))?;
let header_val: serde_json::Value =
serde_json::from_slice(&header_json).map_err(|e| anyhow!("invalid header json: {e}"))?;
let alg_str = header_val.get("alg").and_then(|v| v.as_str()).unwrap_or("");
// If alg is "none", skip signature/JWKS entirely
// --- Policy: reject `alg: none` unless explicitly allowed ------------------
if alg_str.eq_ignore_ascii_case("none") {
// still enforce your time/claims checks that already ran
return Ok((
true,
format!(
"JWT valid (alg: none, iss: {}, aud: {:?})",
claims.iss.clone().unwrap_or_default(),
extract_aud_strings(&claims),
),
));
if opts.allow_alg_none {
// time-valid is enough if explicitly allowed
return Ok((
true,
format!(
"JWT valid (alg: none, iss: {}, aud: {:?})",
claims.iss.clone().unwrap_or_default(),
extract_aud_strings(&claims),
),
));
} else {
return Ok((false, "unsigned JWT (alg: none) not allowed".into()));
}
}
// Safe to decode full header now that we know alg != none
let header = decode_header(token).map_err(|e| anyhow!("decode header: {e}"))?;
let alg = header.alg;
// ---------------------------------------------------------------------------
let issuer = claims.iss.clone().unwrap_or_default();
let aud_strings = extract_aud_strings(&claims);
if issuer.trim().is_empty() && aud_strings.iter().all(|s| s.trim().is_empty()) {
return Ok((false, "JWT missing issuer and audience".into()));
}
if let Some(iss) = claims.iss.clone() {
// parse header now (kid, alg)
let header = decode_header(token).map_err(|e| anyhow!("decode header: {e}"))?;
let alg = header.alg;
// --- New rule: require `iss` OR use fallback key for crypto verification ---
if issuer.trim().is_empty() {
// No issuer — we may still accept if we can cryptographically verify with a fallback key
if let Some(decoding_key) = opts.fallback_decoding_key.as_ref() {
// Verify signature (aud checked if present)
let mut validation = JwtValidation::new(alg);
if !aud_strings.is_empty() {
validation.set_audience(&aud_strings);
}
// We already did exp/nbf manually.
validation.validate_exp = false;
validation.validate_nbf = false;
// build discovery URL and fetch it (redirects disabled)
let config_url = format!("{}/.well-known/openid-configuration", iss.trim_end_matches('/'));
let cfg_resp = NO_REDIRECT_CLIENT
.get(&config_url)
.send()
.await
.map_err(|e| anyhow!("issuer discovery failed: {e}"))?;
decode::<Claims>(token, decoding_key, &validation)
.map_err(|e| anyhow!("signature verification (fallback key) failed: {e}"))?;
if !cfg_resp.status().is_success() {
return Ok((false, format!("issuer discovery failed: {}", cfg_resp.status())));
}
let cfg_json: serde_json::Value =
cfg_resp.json().await.map_err(|e| anyhow!("invalid discovery JSON: {e}"))?;
// extract jwks_uri
let jwks_uri = cfg_json
.get("jwks_uri")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("jwks_uri missing"))?;
// must be HTTPS
let url = Url::parse(jwks_uri).map_err(|e| anyhow!("invalid jwks_uri: {e}"))?;
if url.scheme() != "https" {
return Ok((false, "jwks_uri must use https".to_string()));
}
// host must match issuer host  —  prevents open redirects / SSRF-on-other-host
let iss_host = Url::parse(&iss)
.map_err(|e| anyhow!("invalid iss: {e}"))?
.host_str()
.unwrap_or_default()
.to_ascii_lowercase();
let jwks_host = url.host_str().unwrap_or_default().to_ascii_lowercase();
if jwks_host != iss_host {
return Ok((
true,
format!("JWT valid via fallback key (alg: {:?}, aud: {:?})", alg, aud_strings),
));
} else {
return Ok((
false,
format!("jwks_uri host ({jwks_host}) must match issuer host ({iss_host})"),
"issuer (iss) required or a fallback verification key must be provided".into(),
));
}
}
// -----------------------------------------------------------------------
// DNS resolution + private-range block
for addr in lookup_host((jwks_host.as_str(), 443)).await? {
if is_blocked_ip(addr.ip()) {
return Ok((false, "jwks_uri resolves to private or link-local IP".to_string()));
}
}
// --- With `iss`: OIDC discovery + JWKS verification path -------------------
// build discovery URL and fetch it (redirects disabled)
let config_url = format!("{}/.well-known/openid-configuration", issuer.trim_end_matches('/'));
let cfg_resp = NO_REDIRECT_CLIENT
.get(&config_url)
.send()
.await
.map_err(|e| anyhow!("issuer discovery failed: {e}"))?;
// reachability check (existing helper)
check_url_resolvable(&url).await.map_err(|e| anyhow!("jwks uri unresolvable: {e}"))?;
if !cfg_resp.status().is_success() {
return Ok((false, format!("issuer discovery failed: {}", cfg_resp.status())));
}
// fetch JWKS with redirect-free client
let jwks_resp = NO_REDIRECT_CLIENT
.get(url)
.send()
.await
.map_err(|e| anyhow!("jwks fetch failed: {e}"))?;
if !jwks_resp.status().is_success() {
return Ok((false, format!("jwks fetch failed: {}", jwks_resp.status())));
}
let cfg_json: serde_json::Value =
cfg_resp.json().await.map_err(|e| anyhow!("invalid discovery JSON: {e}"))?;
let jwk_set: JwkSet =
jwks_resp.json().await.map_err(|e| anyhow!("invalid jwks json: {e}"))?;
// extract jwks_uri
let jwks_uri = cfg_json
.get("jwks_uri")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("jwks_uri missing"))?;
// select key by kid
let kid = header.kid.ok_or_else(|| anyhow!("no kid in header"))?;
let jwk = jwk_set
.keys
.iter()
.find(|k| k.common.key_id.as_deref() == Some(&kid))
.ok_or_else(|| anyhow!("kid not found in jwks"))?;
// verify signature
let decoding_key = DecodingKey::from_jwk(jwk).map_err(|e| anyhow!("invalid jwk: {e}"))?;
let mut validation = JwtValidation::new(header.alg);
validation.set_audience(&extract_aud_strings(&claims));
validation.validate_exp = false;
validation.validate_nbf = false;
decode::<Claims>(token, &decoding_key, &validation)
.map_err(|e| anyhow!("signature verification failed: {e}"))?;
// must be HTTPS
let url = Url::parse(jwks_uri).map_err(|e| anyhow!("invalid jwks_uri: {e}"))?;
if url.scheme() != "https" {
return Ok((false, "jwks_uri must use https".to_string()));
}
// host must match issuer host
let iss_host = Url::parse(&issuer)
.map_err(|e| anyhow!("invalid iss: {e}"))?
.host_str()
.unwrap_or_default()
.to_ascii_lowercase();
let jwks_host = url.host_str().unwrap_or_default().to_ascii_lowercase();
if jwks_host != iss_host {
return Ok((
true,
format!(
"JWT valid (alg: {:?}, iss: {issuer}, aud: {:?})",
alg,
extract_aud_strings(&claims)
),
false,
format!("jwks_uri host ({jwks_host}) must match issuer host ({iss_host})"),
));
}
Ok((true, format!("JWT not expired (iss: {issuer}, aud: {:?})", extract_aud_strings(&claims))))
// DNS resolution + private-range block
for addr in lookup_host((jwks_host.as_str(), 443)).await? {
if is_blocked_ip(addr.ip()) {
return Ok((false, "jwks_uri resolves to private or link-local IP".to_string()));
}
}
// reachability check (existing helper)
check_url_resolvable(&url).await.map_err(|e| anyhow!("jwks uri unresolvable: {e}"))?;
// fetch JWKS with redirect-free client
let jwks_resp =
NO_REDIRECT_CLIENT.get(url).send().await.map_err(|e| anyhow!("jwks fetch failed: {e}"))?;
if !jwks_resp.status().is_success() {
return Ok((false, format!("jwks fetch failed: {}", jwks_resp.status())));
}
let jwk_set: JwkSet = jwks_resp.json().await.map_err(|e| anyhow!("invalid jwks json: {e}"))?;
// select key by kid
let kid = header.kid.ok_or_else(|| anyhow!("no kid in header"))?;
let jwk = jwk_set
.keys
.iter()
.find(|k| k.common.key_id.as_deref() == Some(&kid))
.ok_or_else(|| anyhow!("kid not found in jwks"))?;
// verify signature
let decoding_key = DecodingKey::from_jwk(jwk).map_err(|e| anyhow!("invalid jwk: {e}"))?;
let mut validation = JwtValidation::new(header.alg);
if !aud_strings.is_empty() {
validation.set_audience(&aud_strings);
}
validation.validate_exp = false;
validation.validate_nbf = false;
decode::<Claims>(token, &decoding_key, &validation)
.map_err(|e| anyhow!("signature verification failed: {e}"))?;
Ok((true, format!("JWT valid (alg: {:?}, iss: {issuer}, aud: {:?})", alg, aud_strings)))
}
/// Helper: normalize aud into a flat Vec<String>
@ -212,12 +254,11 @@ fn is_blocked_ip(ip: std::net::IpAddr) -> bool {
#[cfg(test)]
mod tests {
use super::{validate_jwt, validate_jwt_with, ValidateOptions};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use chrono::{Duration as ChronoDuration, Utc};
use super::validate_jwt;
fn build_token(exp_offset: i64) -> String {
fn build_unsigned_token(exp_offset: i64) -> String {
let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#);
let exp = (Utc::now() + ChronoDuration::seconds(exp_offset)).timestamp();
let payload = URL_SAFE_NO_PAD.encode(format!(
@ -231,16 +272,35 @@ mod tests {
}
#[tokio::test]
async fn valid_token() {
let token = build_token(60);
async fn unsigned_token_rejected_by_default() {
let token = build_unsigned_token(60);
let res = validate_jwt(&token).await.unwrap();
assert!(res.0);
assert!(!res.0);
assert!(res.1.contains("unsigned JWT (alg: none) not allowed"));
}
#[tokio::test]
async fn expired_token() {
let token = build_token(-60);
let res = validate_jwt(&token).await.unwrap();
async fn valid_token_allows_alg_none_when_opted_in() {
let token = build_unsigned_token(60);
let res = validate_jwt_with(
&token,
&ValidateOptions { allow_alg_none: true, fallback_decoding_key: None },
)
.await
.unwrap();
assert!(res.0, "expected success when alg none is explicitly allowed");
}
#[tokio::test]
async fn expired_token_still_rejected() {
let token = build_unsigned_token(-60);
let res = validate_jwt_with(
&token,
&ValidateOptions { allow_alg_none: true, fallback_decoding_key: None },
)
.await
.unwrap();
assert!(!res.0);
assert!(res.1.contains("expired"));
}
}

View file

@ -81,8 +81,9 @@ fn run_skiplist(skip_regex: Vec<String>, skip_skipword: Vec<String>) -> Result<u
docker_image: Vec::new(),
git_clone: GitCloneMode::Bare,
git_history: GitHistoryMode::Full,
scan_nested_repos: true,
commit_metadata: true,
repo_artifacts: false,
scan_nested_repos: true,
},
content_filtering_args: ContentFilteringArgs {
max_file_size_mb: 5.0,
@ -125,7 +126,8 @@ fn run_skiplist(skip_regex: Vec<String>, skip_skipword: Vec<String>) -> Result<u
rt.block_on(run_async_scan(&global_args, &scan_args, Arc::clone(&datastore), &rules_db))?;
let x = Ok(datastore.lock().unwrap().get_matches().len()); x
let x = Ok(datastore.lock().unwrap().get_matches().len());
x
}
#[test]
@ -140,4 +142,4 @@ fn skip_skipword_filters_match() -> Result<()> {
let count = run_skiplist(Vec::new(), vec!["test".into()])?;
assert_eq!(count, 1);
Ok(())
}
}

View file

@ -97,8 +97,9 @@ rules:
// git clone / history options
git_clone: GitCloneMode::Bare,
git_history: GitHistoryMode::Full,
scan_nested_repos: true,
commit_metadata: true,
repo_artifacts: false,
scan_nested_repos: true,
},
content_filtering_args: ContentFilteringArgs {
max_file_size_mb: 5.0,

View file

@ -84,8 +84,9 @@ fn test_github_remote_scan() -> Result<()> {
// git clone / history options
git_clone: GitCloneMode::Bare,
git_history: GitHistoryMode::Full,
scan_nested_repos: true,
commit_metadata: true,
repo_artifacts: false,
scan_nested_repos: true,
},
content_filtering_args: ContentFilteringArgs {
max_file_size_mb: 25.0,

View file

@ -82,8 +82,9 @@ fn test_gitlab_remote_scan() -> Result<()> {
docker_image: Vec::new(),
git_clone: GitCloneMode::Bare,
git_history: GitHistoryMode::Full,
scan_nested_repos: true,
commit_metadata: true,
repo_artifacts: false,
scan_nested_repos: true,
},
content_filtering_args: ContentFilteringArgs {
max_file_size_mb: 25.0,
@ -188,8 +189,9 @@ fn test_gitlab_remote_scan_no_history() -> Result<()> {
docker_image: Vec::new(),
git_clone: GitCloneMode::Bare,
git_history: GitHistoryMode::None,
scan_nested_repos: true,
commit_metadata: true,
repo_artifacts: false,
scan_nested_repos: true,
},
content_filtering_args: ContentFilteringArgs {
max_file_size_mb: 25.0,

View file

@ -64,8 +64,9 @@ async fn test_redact_hashes_finding_values() -> Result<()> {
docker_image: Vec::new(),
git_clone: GitCloneMode::Bare,
git_history: GitHistoryMode::Full,
scan_nested_repos: true,
commit_metadata: true,
repo_artifacts: false,
scan_nested_repos: true,
},
content_filtering_args: ContentFilteringArgs {
max_file_size_mb: 25.0,

View file

@ -70,8 +70,9 @@ impl TestContext {
docker_image: Vec::new(),
git_clone: GitCloneMode::Bare,
git_history: GitHistoryMode::Full,
scan_nested_repos: true,
commit_metadata: true,
repo_artifacts: false,
scan_nested_repos: true,
},
content_filtering_args: ContentFilteringArgs {
max_file_size_mb: 25.0,
@ -166,8 +167,9 @@ async fn test_scan_slack_messages() -> Result<()> {
docker_image: Vec::new(),
git_clone: GitCloneMode::Bare,
git_history: GitHistoryMode::Full,
scan_nested_repos: true,
commit_metadata: true,
repo_artifacts: false,
scan_nested_repos: true,
},
content_filtering_args: ContentFilteringArgs {
max_file_size_mb: 25.0,

View file

@ -140,8 +140,9 @@ async fn test_validation_cache_and_depvars() -> Result<()> {
// git clone / history options
git_clone: GitCloneMode::Bare,
git_history: GitHistoryMode::Full,
scan_nested_repos: true,
commit_metadata: true,
repo_artifacts: false,
scan_nested_repos: true,
},
content_filtering_args: ContentFilteringArgs {
max_file_size_mb: 25.0,

View file

@ -83,8 +83,9 @@ impl TestContext {
// git clone / history options
git_clone: GitCloneMode::Bare,
git_history: GitHistoryMode::Full,
scan_nested_repos: true,
commit_metadata: true,
repo_artifacts: false,
scan_nested_repos: true,
},
content_filtering_args: ContentFilteringArgs {
max_file_size_mb: 25.0,
@ -164,8 +165,9 @@ impl TestContext {
// git clone / history options
git_clone: GitCloneMode::Bare,
git_history: GitHistoryMode::Full,
scan_nested_repos: true,
commit_metadata: true,
repo_artifacts: false,
scan_nested_repos: true,
},
content_filtering_args: ContentFilteringArgs {
max_file_size_mb: 25.0,

View file

@ -108,4 +108,4 @@ fn baseline_exclude_prunes_entries() -> anyhow::Result<()> {
assert!(!content.contains(".git/secret.txt"));
Ok(())
}
}