From 9ce7343d2ca3a61473a665e29e8d55901c17ab34 Mon Sep 17 00:00:00 2001 From: gersbach Date: Tue, 3 Dec 2024 13:24:15 -0800 Subject: [PATCH] EAS-2497 : Collect possible GraphQL queries during lowering --- Cargo.lock | 45 +++++++ Cargo.toml | 1 + crates/forge_analyzer/src/checkers.rs | 8 +- crates/fsrt/Cargo.toml | 1 + crates/fsrt/src/main.rs | 125 +++++++++++++++++++- crates/fsrt/src/test.rs | 163 ++++++++++++++++++++++++-- 6 files changed, 323 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 759b654..3a1d7bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,6 +117,12 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" +[[package]] +name = "ascii" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eab1c04a571841102f5345a8fc0f6bb3d31c315dec879b5c6e42e40ce7ffa34e" + [[package]] name = "ast_node" version = "0.9.9" @@ -340,6 +346,19 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" +[[package]] +name = "combine" +version = "3.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3da6baa321ec19e1cc41d31bf599f00c783d0517095cdaf0332e3fe8d20680" +dependencies = [ + "ascii", + "byteorder", + "either", + "memchr", + "unreachable", +] + [[package]] name = "core-foundation-sys" version = "0.8.6" @@ -590,6 +609,7 @@ dependencies = [ "forge_file_resolver", "forge_loader", "forge_permission_resolver", + "graphql-parser", "rustc-hash 2.0.0", "serde", "serde_json", @@ -628,6 +648,16 @@ dependencies = [ "wasi", ] +[[package]] +name = "graphql-parser" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ebc8013b4426d5b81a4364c419a95ed0b404af2b82e2457de52d9348f0e474" +dependencies = [ + "combine", + "thiserror", +] + [[package]] name = "hashbrown" version = "0.13.2" @@ -2886,6 +2916,15 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" +[[package]] +name = "unreachable" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "382810877fe448991dfc7f0dd6e3ae5d58088fd0ea5e35189655f84e6814fa56" +dependencies = [ + "void", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -2964,6 +3003,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index e35d48d..72be784 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ serde = { version = "1.0.197", features = ["derive"] } serde_json = "1.0.115" serde_yaml = "0.9.34" petgraph = "0.6.4" +graphql-parser = "0.4.0" pretty_assertions = "1.4.0" indexmap = { version = "2.2.6", features = ["std"] } regex = "1.10.4" diff --git a/crates/forge_analyzer/src/checkers.rs b/crates/forge_analyzer/src/checkers.rs index d5a67aa..f78889e 100644 --- a/crates/forge_analyzer/src/checkers.rs +++ b/crates/forge_analyzer/src/checkers.rs @@ -1242,7 +1242,7 @@ impl Default for PermissionChecker { impl fmt::Display for PermissionVuln { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "Authentication vulnerability") + write!(f, "Permission vulnerability") } } @@ -1483,11 +1483,7 @@ impl<'cx> Dataflow<'cx> for DefinitionAnalysisRunner { /* should be expanded to include all cases ... */ interp.add_value_to_definition(def, var.clone(), rvalue.clone()); } - Rvalue::Template(_) => { - interp.add_value_to_definition(def, var.clone(), rvalue.clone()); - } - - _ => {} + _ => interp.add_value_to_definition(def, var.clone(), rvalue.clone()), } self.transfer_rvalue(interp, def, loc, block, rvalue, initial_state) } diff --git a/crates/fsrt/Cargo.toml b/crates/fsrt/Cargo.toml index 76581b4..91ccce9 100644 --- a/crates/fsrt/Cargo.toml +++ b/crates/fsrt/Cargo.toml @@ -25,3 +25,4 @@ tracing-subscriber.workspace = true tracing-tree.workspace = true tracing.workspace = true walkdir.workspace = true +graphql-parser = "0.4.0" diff --git a/crates/fsrt/src/main.rs b/crates/fsrt/src/main.rs index 7ed1220..a29522d 100644 --- a/crates/fsrt/src/main.rs +++ b/crates/fsrt/src/main.rs @@ -10,12 +10,13 @@ use forge_permission_resolver::permissions_resolver::{ }; use std::{ - collections::HashSet, + collections::{HashMap, HashSet}, fmt, fs, os::unix::prelude::OsStrExt, path::{Path, PathBuf}, }; +use graphql_parser::query::{parse_query, Definition, OperationDefinition, Selection}; use tracing::{debug, warn}; use tracing_subscriber::{prelude::*, EnvFilter}; use tracing_tree::HierarchicalLayer; @@ -26,7 +27,7 @@ use forge_analyzer::{ PermissionVuln, SecretChecker, }, ctx::ModId, - definitions::{DefId, PackageData}, + definitions::{Const, DefId, PackageData, Value}, interp::Interp, reporter::{Report, Reporter}, }; @@ -34,6 +35,7 @@ use forge_analyzer::{ use crate::forge_project::{ForgeProjectFromDir, ForgeProjectTrait}; use forge_loader::manifest::Entrypoint; use walkdir::WalkDir; + type Result = std::result::Result>; #[derive(Parser, Debug)] @@ -90,6 +92,8 @@ pub enum Error { UnableToReport, } +impl std::error::Error for Error {} + impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -99,7 +103,90 @@ impl fmt::Display for Error { } } -impl std::error::Error for Error {} +fn check_graphql_and_perms(val: &Value) -> Vec<&str> { + let mut operations = vec![]; + match val { + Value::Const(Const::Literal(s)) => operations.extend(parse_graphql(s)), + Value::Phi(vals) => vals.iter().for_each(|val| match val { + Const::Literal(s) => operations.extend(parse_graphql(s)), + }), + _ => {} + } + // TODO : Build out permission resolver here + + let permissions_resolver: HashMap<(&str, &str), &str> = + [(("compass", "searchTeams"), "read:component:compass")] + .into_iter() + .collect(); + + operations + .iter() + .filter_map(|f| permissions_resolver.get(f).copied()) + .collect() +} + +// returns (product, operationName) +fn parse_graphql(s: &str) -> impl Iterator { + let mut operations = vec![]; + + // collect all fragments + if let std::result::Result::Ok(doc) = parse_query::<&str>(s) { + let fragments: HashMap<&str, &Vec>> = doc + .definitions + .iter() + .filter_map(|def| match def { + Definition::Fragment(fragment) => { + Some((fragment.name, fragment.selection_set.items.as_ref())) + } + _ => None, + }) + .collect(); + + doc.definitions.iter().for_each(|operation| { + if let Definition::Operation(op) = operation { + let possible_selection_set = match op { + OperationDefinition::Mutation(mutation) => Some(&mutation.selection_set), + OperationDefinition::Query(query) => Some(&query.selection_set), + OperationDefinition::SelectionSet(set) => Some(set), + _ => None, + }; + // place all fragments in place of the fragment spread + if let Some(selection_set) = possible_selection_set { + selection_set.items.iter().for_each(|selection| { + if let Selection::Field(type_field) = selection { + type_field + .selection_set + .items + .iter() + .for_each(|fragment_selections| { + if let Selection::Field(operation) = fragment_selections { + operations.push((type_field.name, operation.name)) + } else if let Selection::FragmentSpread(fragment_spread) = + fragment_selections + { + // check to see if the fragment spread resolves as fragmemnt + if let Some(set) = + fragments.get(&fragment_spread.fragment_name) + { + set.iter().for_each(|operation_field| { + if let Selection::Field(operation) = operation_field + { + operations + .push((type_field.name, operation.name)) + } + }); + } + } + }); + } + }) + } + } + }) + } + + operations.into_iter() +} fn is_js_file>(path: P) -> bool { matches!( @@ -349,7 +436,37 @@ pub(crate) fn scan_directory<'a>( } } - if run_permission_checker && !perm_interp.permissions.is_empty() { + let mut used_graphql_perms: Vec<&str> = definition_analysis_interp + .value_manager + .varid_to_value_with_proj + .values() + .flat_map(check_graphql_and_perms) + .collect(); + + let graphql_perms_varid: Vec<&str> = definition_analysis_interp + .value_manager + .varid_to_value + .values() + .flat_map(check_graphql_and_perms) + .collect(); + + let graphql_perms_defid: Vec<&str> = definition_analysis_interp + .value_manager + .defid_to_value + .values() + .flat_map(check_graphql_and_perms) + .collect(); + + used_graphql_perms.extend_from_slice(&graphql_perms_defid); + used_graphql_perms.extend_from_slice(&graphql_perms_varid); + + let final_perms: Vec<&String> = perm_interp + .permissions + .iter() + .filter(|f| !used_graphql_perms.contains(&&***f)) + .collect(); + + if run_permission_checker && !final_perms.is_empty() { reporter.add_vulnerabilities([PermissionVuln::new(perm_interp.permissions)]); } diff --git a/crates/fsrt/src/test.rs b/crates/fsrt/src/test.rs index be10c26..d0bf779 100644 --- a/crates/fsrt/src/test.rs +++ b/crates/fsrt/src/test.rs @@ -17,6 +17,8 @@ trait ReportExt { fn contains_secret_vuln(&self, expected_len: usize) -> bool; + fn contains_perm_vuln(&self, expected_len: usize) -> bool; + fn contains_vulns(&self, expected_len: i32) -> bool; fn contains_authz_vuln(&self, expected_len: usize) -> bool; @@ -28,20 +30,29 @@ impl ReportExt for Report { self.into_vulns().is_empty() } + #[inline] + fn contains_authz_vuln(&self, expected_len: usize) -> bool { + self.into_vulns() + .iter() + .filter(|vuln| vuln.check_name().contains("Authorizatio")) + .count() + == expected_len + } + #[inline] fn contains_secret_vuln(&self, expected_len: usize) -> bool { self.into_vulns() .iter() - .filter(|vuln| vuln.check_name().starts_with("Hardcoded-Secret")) + .filter(|vuln| vuln.check_name().starts_with("Hardcoded-Secret-")) .count() == expected_len } #[inline] - fn contains_authz_vuln(&self, expected_len: usize) -> bool { + fn contains_perm_vuln(&self, expected_len: usize) -> bool { self.into_vulns() .iter() - .filter(|vuln| vuln.check_name().starts_with("Custom-Check-Authorization")) + .filter(|vuln| vuln.check_name() == "Least-Privilege") .count() == expected_len } @@ -96,7 +107,6 @@ impl<'a> MockForgeProject<'a> { }; for file in different_files { - println!("files {file:?}"); let (file_name, file_source) = file.split_once('\n').unwrap(); if file_name.trim() == "manifest.yml" || file_name.trim() == "manifest.yaml" { continue; @@ -148,12 +158,10 @@ pub(crate) fn scan_directory_test( .map(|f| serde_yaml::from_reader(f).expect("Failed to deserialize packages")) .unwrap_or_else(|_| vec![]); - match scan_directory( - PathBuf::new(), - &Args::parse(), - forge_test_proj, - &secret_packages, - ) { + let mut args = Args::parse(); + args.check_permissions = true; + + match scan_directory(PathBuf::new(), &args, forge_test_proj, &secret_packages) { Ok(report) => report, Err(err) => panic!("error while scanning {err:?}"), } @@ -627,6 +635,141 @@ fn basic_authz_vuln() { ); let scan_result = scan_directory_test(test_forge_project); + println!("vuln, {:#?}", scan_result); assert!(scan_result.contains_authz_vuln(1)); assert!(scan_result.contains_vulns(1)); } + +#[test] +fn excess_scope() { + let mut test_forge_project = MockForgeProject::files_from_string( + "// src/index.tsx + import ForgeUI, { render, Macro } from '@forge/ui'; + import * as atlassian_jwt from 'atlassian-jwt'; + + function App() { + + } + + export const run = render(} />); + ", + ); + + test_forge_project + .test_manifest + .permissions + .scopes + .push("read:component:compass".into()); + + let scan_result = scan_directory_test(test_forge_project); + println!("scan_result {:#?}", scan_result); + assert!(scan_result.contains_perm_vuln(1)); + assert!(scan_result.contains_vulns(1)) +} + +#[test] +fn correct_scopes() { + let mut test_forge_project = MockForgeProject::files_from_string( + "// src/index.tsx + import ForgeUI, { render, Macro } from '@forge/ui'; + import * as atlassian_jwt from 'atlassian-jwt'; + + function App() { + + const query = `query compass_query($test:CompassSearchTeamsInput!) { + compass { + searchTeams(input: $test) { + ... on CompassSearchTeamsConnection{ + nodes { + teamId + } + } + } + } + }` + + const result = await api + .asApp() + .requestGraph( + query, {}, {} + ); + const status = result.status; + + } + + export const run = render(} />); + ", + ); + + test_forge_project + .test_manifest + .permissions + .scopes + .push("read:component:compass".into()); + + let scan_result = scan_directory_test(test_forge_project); + println!("scan_result {:#?}", scan_result); + assert!(scan_result.contains_vulns(0)) +} + +#[test] +fn excess_scope_with_fragments() { + let mut test_forge_project = MockForgeProject::files_from_string( + "// src/index.tsx + import ForgeUI, { render, Macro } from '@forge/ui'; + import * as atlassian_jwt from 'atlassian-jwt'; + + function App() { + + } + + const check = `fragment componentParts on CompassCatalogQueryApi{ __typename } + + query compass_query($test:CompassSearchTeamsInput!) { compass { ...componentParts } }` + + export const run = render(} />); + ", + ); + + test_forge_project + .test_manifest + .permissions + .scopes + .push("read:component:compass".into()); + + let scan_result = scan_directory_test(test_forge_project); + println!("scan_result {:#?}", scan_result); + assert!(scan_result.contains_perm_vuln(1)); + assert!(scan_result.contains_vulns(1)) +} + +#[test] +fn correct_scopes_with_fragment() { + let mut test_forge_project = MockForgeProject::files_from_string( + "// src/index.tsx + import ForgeUI, { render, Macro } from '@forge/ui'; + import * as atlassian_jwt from 'atlassian-jwt'; + + function App() { + + const check = `fragment componentParts on CompassCatalogQueryApi{ searchTeams(input: $test) + { ... on CompassSearchTeamsConnection{ nodes { teamId } } } } + + query compass_query($test:CompassSearchTeamsInput!) { compass { ...componentParts } }` + + } + + export const run = render(} />); + ", + ); + + test_forge_project + .test_manifest + .permissions + .scopes + .push("read:component:compass".into()); + + let scan_result = scan_directory_test(test_forge_project); + println!("scan_result {:#?}", scan_result); + assert!(scan_result.contains_vulns(0)) +}