- Added '--repo-artifacts' flag to scan repository issues, gists/snippets, and wikis when cloning via '--git-url'

This commit is contained in:
Mick Grove 2025-08-20 20:41:11 -07:00
commit 6e4c94ddc3
19 changed files with 470 additions and 22 deletions

View file

@ -2,6 +2,9 @@
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`
## [1.44.0]
- Fixed issue with self-update on Linux
- Reverted the change to json and jsonl outputs by rule

View file

@ -369,12 +369,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 +406,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

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

@ -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

@ -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

@ -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,

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,