2025-06-24 17:17:16 -07:00
|
|
|
// tests/int_validation_cache.rs
|
|
|
|
|
use std::{
|
|
|
|
|
fs,
|
|
|
|
|
sync::{
|
|
|
|
|
Arc, Mutex,
|
2026-04-17 16:53:21 -07:00
|
|
|
atomic::{AtomicUsize, Ordering},
|
2025-06-24 17:17:16 -07:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
use anyhow::Result;
|
|
|
|
|
use kingfisher::{
|
|
|
|
|
cli::{
|
2026-04-17 16:53:21 -07:00
|
|
|
GlobalArgs,
|
2025-06-24 17:17:16 -07:00
|
|
|
commands::{
|
2025-10-04 23:12:28 -07:00
|
|
|
azure::AzureRepoType,
|
2025-09-22 18:21:03 -07:00
|
|
|
bitbucket::{BitbucketAuthArgs, BitbucketRepoType},
|
2025-09-23 13:07:45 -07:00
|
|
|
gitea::GiteaRepoType,
|
2025-06-24 17:17:16 -07:00
|
|
|
github::{GitCloneMode, GitHistoryMode, GitHubRepoType},
|
|
|
|
|
gitlab::GitLabRepoType,
|
|
|
|
|
inputs::{ContentFilteringArgs, InputSpecifierArgs},
|
|
|
|
|
output::{OutputArgs, ReportOutputFormat},
|
|
|
|
|
rules::RuleSpecifierArgs,
|
|
|
|
|
scan::{ConfidenceLevel, ScanArgs},
|
|
|
|
|
},
|
2026-02-02 23:22:08 -08:00
|
|
|
global::{Mode, TlsMode},
|
2025-06-24 17:17:16 -07:00
|
|
|
},
|
|
|
|
|
findings_store::FindingsStore,
|
|
|
|
|
rule_loader::RuleLoader,
|
|
|
|
|
rules_database::RulesDatabase,
|
|
|
|
|
scanner::run_async_scan,
|
2025-11-24 11:08:31 -08:00
|
|
|
update::UpdateStatus,
|
2025-06-24 17:17:16 -07:00
|
|
|
};
|
|
|
|
|
use tempfile::TempDir;
|
|
|
|
|
use url::Url;
|
|
|
|
|
use wiremock::{
|
|
|
|
|
Mock, MockServer, Request, ResponseTemplate,
|
2026-04-17 16:53:21 -07:00
|
|
|
matchers::{method, path},
|
2025-06-24 17:17:16 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_validation_cache_and_depvars() -> Result<()> {
|
|
|
|
|
/* --------------------------------------------------------- *
|
|
|
|
|
* 1. Spin-up Wiremock and count incoming validation calls *
|
|
|
|
|
* --------------------------------------------------------- */
|
|
|
|
|
let server = MockServer::start().await;
|
|
|
|
|
let hit_counter = Arc::new(AtomicUsize::new(0));
|
|
|
|
|
let counter_clone = Arc::clone(&hit_counter);
|
|
|
|
|
|
|
|
|
|
Mock::given(method("GET"))
|
|
|
|
|
.and(path("/validate"))
|
|
|
|
|
.respond_with(move |_req: &Request| {
|
|
|
|
|
counter_clone.fetch_add(1, Ordering::SeqCst);
|
|
|
|
|
ResponseTemplate::new(200).set_body_string("ok")
|
|
|
|
|
})
|
|
|
|
|
.mount(&server)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
/* --------------------------------------------------------- *
|
|
|
|
|
* 2. Synthetic rules exercising depends_on_rule + HTTP val *
|
|
|
|
|
* --------------------------------------------------------- */
|
|
|
|
|
let rules_yaml = format!(
|
|
|
|
|
r#"
|
|
|
|
|
rules:
|
|
|
|
|
- name: Demo API Key
|
|
|
|
|
id: demo.key.1
|
|
|
|
|
pattern: '(demokey_[a-z0-9]{{8}})'
|
|
|
|
|
confidence: low
|
|
|
|
|
min_entropy: 0.0
|
|
|
|
|
|
|
|
|
|
- name: Demo API Key Validation
|
|
|
|
|
id: demo.key.validation.1
|
|
|
|
|
depends_on_rule:
|
|
|
|
|
- rule_id: demo.key.1
|
|
|
|
|
variable: TOKEN
|
|
|
|
|
pattern: '(demokey_[a-z0-9]{{8}})'
|
|
|
|
|
confidence: low
|
|
|
|
|
validation:
|
|
|
|
|
type: Http
|
|
|
|
|
content:
|
|
|
|
|
request:
|
|
|
|
|
method: GET
|
|
|
|
|
url: '{base}/validate?token={{ {{ TOKEN }} }}'
|
2025-06-25 23:29:46 -07:00
|
|
|
response_matcher:
|
|
|
|
|
- report_response: true
|
|
|
|
|
- type: WordMatch
|
|
|
|
|
words:
|
|
|
|
|
- '"error_code":"403003"'
|
|
|
|
|
negative: true
|
2025-06-24 17:17:16 -07:00
|
|
|
"#,
|
|
|
|
|
base = server.uri()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
/* --------------------------------------------------------- *
|
|
|
|
|
* 3. Temp workspace: rules file + input with 2 duplicates *
|
|
|
|
|
* --------------------------------------------------------- */
|
|
|
|
|
let work_dir = TempDir::new()?;
|
|
|
|
|
let rules_file = work_dir.path().join("demo.yml");
|
|
|
|
|
fs::write(&rules_file, rules_yaml)?;
|
|
|
|
|
|
|
|
|
|
let secret_file = work_dir.path().join("secrets.txt");
|
|
|
|
|
fs::write(&secret_file, "demokey_abcdefgh\ndemokey_abcdefgh")?;
|
|
|
|
|
|
|
|
|
|
/* --------------------------------------------------------- *
|
|
|
|
|
* 4. Build Scan / Global args (no_dedup=true to keep dups) *
|
|
|
|
|
* --------------------------------------------------------- */
|
|
|
|
|
let scan_args = ScanArgs {
|
|
|
|
|
num_jobs: 2,
|
|
|
|
|
rules: RuleSpecifierArgs {
|
|
|
|
|
rules_path: vec![work_dir.path().to_path_buf()],
|
|
|
|
|
rule: vec!["all".into()],
|
|
|
|
|
load_builtins: false,
|
|
|
|
|
},
|
|
|
|
|
input_specifier_args: InputSpecifierArgs {
|
|
|
|
|
path_inputs: vec![secret_file.clone()],
|
|
|
|
|
git_url: Vec::new(),
|
2026-01-01 22:24:57 -08:00
|
|
|
git_clone_dir: None,
|
|
|
|
|
keep_clones: false,
|
|
|
|
|
repo_clone_limit: None,
|
|
|
|
|
include_contributors: false,
|
2025-06-24 17:17:16 -07:00
|
|
|
github_user: Vec::new(),
|
|
|
|
|
github_organization: Vec::new(),
|
2025-09-15 21:26:51 -07:00
|
|
|
github_exclude: Vec::new(),
|
2025-06-24 17:17:16 -07:00
|
|
|
all_github_organizations: false,
|
|
|
|
|
github_api_url: Url::parse("https://api.github.com/").unwrap(),
|
|
|
|
|
github_repo_type: GitHubRepoType::Source,
|
|
|
|
|
|
|
|
|
|
// new GitLab defaults
|
|
|
|
|
gitlab_user: Vec::new(),
|
|
|
|
|
gitlab_group: Vec::new(),
|
2025-09-15 21:26:51 -07:00
|
|
|
gitlab_exclude: Vec::new(),
|
2025-06-24 17:17:16 -07:00
|
|
|
all_gitlab_groups: false,
|
|
|
|
|
gitlab_api_url: Url::parse("https://gitlab.com/").unwrap(),
|
|
|
|
|
gitlab_repo_type: GitLabRepoType::Owner,
|
2025-08-14 09:25:18 -07:00
|
|
|
gitlab_include_subgroups: false,
|
2025-09-23 13:07:45 -07:00
|
|
|
|
2025-10-15 22:47:40 -07:00
|
|
|
huggingface_user: Vec::new(),
|
|
|
|
|
huggingface_organization: Vec::new(),
|
|
|
|
|
huggingface_model: Vec::new(),
|
|
|
|
|
huggingface_dataset: Vec::new(),
|
|
|
|
|
huggingface_space: Vec::new(),
|
|
|
|
|
huggingface_exclude: Vec::new(),
|
|
|
|
|
|
2025-09-23 13:07:45 -07:00
|
|
|
gitea_user: Vec::new(),
|
|
|
|
|
gitea_organization: Vec::new(),
|
|
|
|
|
gitea_exclude: Vec::new(),
|
|
|
|
|
all_gitea_organizations: false,
|
|
|
|
|
gitea_api_url: Url::parse("https://gitea.com/api/v1/").unwrap(),
|
|
|
|
|
gitea_repo_type: GiteaRepoType::Source,
|
|
|
|
|
|
2025-09-22 18:21:03 -07:00
|
|
|
bitbucket_user: Vec::new(),
|
|
|
|
|
bitbucket_workspace: Vec::new(),
|
|
|
|
|
bitbucket_project: Vec::new(),
|
|
|
|
|
bitbucket_exclude: Vec::new(),
|
|
|
|
|
all_bitbucket_workspaces: false,
|
|
|
|
|
bitbucket_api_url: Url::parse("https://api.bitbucket.org/2.0/").unwrap(),
|
|
|
|
|
bitbucket_repo_type: BitbucketRepoType::Source,
|
|
|
|
|
bitbucket_auth: BitbucketAuthArgs::default(),
|
2025-06-24 17:17:16 -07:00
|
|
|
|
2025-10-04 23:12:28 -07:00
|
|
|
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,
|
|
|
|
|
|
2025-07-25 17:21:28 -07:00
|
|
|
jira_url: None,
|
|
|
|
|
jql: None,
|
2026-02-28 11:13:00 -07:00
|
|
|
jira_include_comments: false,
|
|
|
|
|
jira_include_changelog: false,
|
2025-08-10 21:51:31 -07:00
|
|
|
confluence_url: None,
|
|
|
|
|
cql: None,
|
2025-07-27 12:20:20 -07:00
|
|
|
max_results: 100,
|
2025-07-29 19:00:49 -07:00
|
|
|
slack_query: None,
|
|
|
|
|
slack_api_url: Url::parse("https://slack.com/api/").unwrap(),
|
2026-03-13 17:39:34 -07:00
|
|
|
teams_query: None,
|
|
|
|
|
teams_api_url: Url::parse("https://graph.microsoft.com/").unwrap(),
|
Added first-class **Postman** scanning target: new kingfisher scan postman subcommand (and equivalent --postman-* flags) fetches workspaces, collections, and environments via the Postman API and scans them for hard-coded credentials in request auth blocks, pre-request/test scripts, saved example responses, and — notably — secret-typed environment variables, which the API returns in plaintext despite the UI mask. Selectors: --workspace, --collection, --environment, --all, with optional --include-mocks-monitors and --api-url for self-hosted endpoints. Authenticates via KF_POSTMAN_TOKEN (or POSTMAN_API_KEY) sent as X-Api-Key; honors X-RateLimit-RetryAfter on 429s. Findings link back to https://go.postman.co/... URLs in reports.
2026-04-29 08:12:08 -07:00
|
|
|
postman_workspaces: Vec::new(),
|
|
|
|
|
postman_collections: Vec::new(),
|
|
|
|
|
postman_environments: Vec::new(),
|
|
|
|
|
postman_all: false,
|
|
|
|
|
postman_include_mocks_monitors: false,
|
|
|
|
|
postman_api_url: Url::parse("https://api.getpostman.com/").unwrap(),
|
2025-08-02 20:40:16 -07:00
|
|
|
// s3
|
|
|
|
|
s3_bucket: None,
|
|
|
|
|
s3_prefix: None,
|
|
|
|
|
role_arn: None,
|
|
|
|
|
aws_local_profile: None,
|
2025-10-15 22:47:40 -07:00
|
|
|
gcs_bucket: None,
|
|
|
|
|
gcs_prefix: None,
|
|
|
|
|
gcs_service_account: None,
|
2025-07-27 12:20:20 -07:00
|
|
|
// Docker image scanning
|
|
|
|
|
docker_image: Vec::new(),
|
2026-05-28 13:54:59 -07:00
|
|
|
docker_archive: Vec::new(),
|
2025-06-24 17:17:16 -07:00
|
|
|
// git clone / history options
|
|
|
|
|
git_clone: GitCloneMode::Bare,
|
|
|
|
|
git_history: GitHistoryMode::Full,
|
|
|
|
|
commit_metadata: true,
|
2025-08-20 20:41:11 -07:00
|
|
|
repo_artifacts: false,
|
|
|
|
|
scan_nested_repos: true,
|
2025-09-16 14:20:43 -07:00
|
|
|
since_commit: None,
|
|
|
|
|
branch: None,
|
2025-10-25 17:12:51 -07:00
|
|
|
branch_root: false,
|
|
|
|
|
branch_root_commit: None,
|
2025-12-09 12:56:55 -08:00
|
|
|
staged: false,
|
2025-06-24 17:17:16 -07:00
|
|
|
},
|
|
|
|
|
content_filtering_args: ContentFilteringArgs {
|
|
|
|
|
max_file_size_mb: 25.0,
|
|
|
|
|
extraction_depth: 2,
|
|
|
|
|
no_binary: true,
|
|
|
|
|
no_extract_archives: false,
|
2025-07-14 13:18:24 -07:00
|
|
|
exclude: Vec::new(), // Exclude patterns
|
2025-06-24 17:17:16 -07:00
|
|
|
},
|
|
|
|
|
confidence: ConfidenceLevel::Low,
|
|
|
|
|
no_validate: false,
|
2025-12-04 22:02:30 -08:00
|
|
|
access_map: false,
|
2025-06-24 17:17:16 -07:00
|
|
|
rule_stats: false,
|
|
|
|
|
only_valid: false,
|
|
|
|
|
min_entropy: Some(0.0),
|
|
|
|
|
redact: false,
|
|
|
|
|
git_repo_timeout: 1800, // 30 minutes
|
|
|
|
|
output_args: OutputArgs { output: None, format: ReportOutputFormat::Pretty },
|
|
|
|
|
no_dedup: true, // keep duplicates so the cache is stressed
|
2026-01-01 22:24:57 -08:00
|
|
|
view_report: false,
|
2025-07-14 13:18:24 -07:00
|
|
|
baseline_file: None,
|
|
|
|
|
manage_baseline: false,
|
2025-08-19 19:18:25 -07:00
|
|
|
skip_regex: Vec::new(),
|
|
|
|
|
skip_word: Vec::new(),
|
2025-10-15 22:47:40 -07:00
|
|
|
skip_aws_account: Vec::new(),
|
|
|
|
|
skip_aws_account_file: None,
|
2025-08-30 19:40:11 -07:00
|
|
|
no_base64: false,
|
2026-02-24 12:25:12 -07:00
|
|
|
turbo: false,
|
2025-10-10 16:23:41 -07:00
|
|
|
extra_ignore_comments: Vec::new(),
|
2025-10-09 20:11:31 -07:00
|
|
|
no_inline_ignore: false,
|
2025-11-05 17:19:11 -08:00
|
|
|
no_ignore_if_contains: false,
|
2026-02-26 23:14:18 -07:00
|
|
|
view_report_port: 7890,
|
|
|
|
|
view_report_address: "127.0.0.1".to_string(),
|
2026-01-01 22:24:57 -08:00
|
|
|
validation_retries: 1,
|
2026-02-12 12:33:59 -08:00
|
|
|
validation_rps: None,
|
|
|
|
|
validation_rps_rule: Vec::new(),
|
2026-01-01 22:24:57 -08:00
|
|
|
validation_timeout: 10,
|
2026-02-09 12:11:35 -08:00
|
|
|
full_validation_response: false,
|
2026-03-16 22:25:32 -07:00
|
|
|
max_validation_response_length: 2048,
|
2026-05-04 13:26:11 -07:00
|
|
|
alert_webhook: Vec::new(),
|
|
|
|
|
alert_format: None,
|
|
|
|
|
alert_on: kingfisher::alerts::AlertOn::Findings,
|
|
|
|
|
alert_min_confidence: ConfidenceLevel::Medium,
|
|
|
|
|
alert_include_secret: false,
|
|
|
|
|
alert_report_url: None,
|
|
|
|
|
alert_detail: kingfisher::alerts::AlertDetail::Auto,
|
|
|
|
|
config_webhook_overrides: Vec::new(),
|
2025-06-24 17:17:16 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* --------------------------------------------------------- *
|
|
|
|
|
* 5. Load rules, run scan *
|
|
|
|
|
* --------------------------------------------------------- */
|
|
|
|
|
// ---------------------------------------------------------
|
|
|
|
|
// 5. Load rules, record them, run scan
|
|
|
|
|
// ---------------------------------------------------------
|
|
|
|
|
let loaded = RuleLoader::from_rule_specifiers(&scan_args.rules).load(&scan_args)?;
|
|
|
|
|
let resolved = loaded.resolve_enabled_rules()?;
|
|
|
|
|
let rules_db = Arc::new(RulesDatabase::from_rules(resolved.into_iter().cloned().collect())?);
|
|
|
|
|
|
|
|
|
|
let datastore = Arc::new(Mutex::new(FindingsStore::new(work_dir.path().to_path_buf())));
|
|
|
|
|
|
|
|
|
|
// NEW: make the datastore aware of every rule
|
|
|
|
|
{
|
|
|
|
|
let mut ds = datastore.lock().unwrap();
|
|
|
|
|
ds.record_rules(rules_db.rules()); // <-- **add this line**
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let global_args = GlobalArgs {
|
|
|
|
|
verbose: 0,
|
|
|
|
|
quiet: true,
|
|
|
|
|
color: Mode::Auto,
|
|
|
|
|
progress: Mode::Never,
|
|
|
|
|
no_update_check: false,
|
|
|
|
|
self_update: false,
|
|
|
|
|
ignore_certs: false,
|
2025-09-18 17:02:56 -07:00
|
|
|
user_agent_suffix: None,
|
2026-02-02 23:22:08 -08:00
|
|
|
tls_mode: TlsMode::Strict,
|
2026-03-27 17:22:21 -07:00
|
|
|
allow_internal_ips: true,
|
2026-04-28 19:21:44 -07:00
|
|
|
endpoint: Vec::new(),
|
|
|
|
|
endpoint_config: None,
|
2026-05-04 13:26:11 -07:00
|
|
|
config: None,
|
2025-06-24 17:17:16 -07:00
|
|
|
};
|
2025-11-24 11:08:31 -08:00
|
|
|
let update_status = UpdateStatus::default();
|
2025-06-24 17:17:16 -07:00
|
|
|
|
2026-04-29 22:50:31 -07:00
|
|
|
run_async_scan(
|
|
|
|
|
&global_args,
|
|
|
|
|
&scan_args,
|
|
|
|
|
Arc::clone(&datastore),
|
|
|
|
|
&rules_db,
|
|
|
|
|
&update_status,
|
|
|
|
|
false,
|
|
|
|
|
)
|
|
|
|
|
.await?;
|
2025-06-24 17:17:16 -07:00
|
|
|
|
|
|
|
|
/* --------------------------------------------------------- *
|
|
|
|
|
* 6. Assertions *
|
|
|
|
|
* --------------------------------------------------------- */
|
|
|
|
|
// There are two matches for demo.key.validation.1, but the validator
|
|
|
|
|
// should have been called only once thanks to SkipMap caching.
|
|
|
|
|
assert_eq!(
|
|
|
|
|
hit_counter.load(Ordering::SeqCst),
|
|
|
|
|
1,
|
|
|
|
|
"validator endpoint should be hit exactly once"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let ds = datastore.lock().unwrap();
|
|
|
|
|
let total_matches = ds.get_matches().len();
|
|
|
|
|
assert_eq!(total_matches, 4, "expected 2 matches per rule (dup secrets)"); // 2 for each rule
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|