Skip to content

Commit 9c78adc

Browse files
author
gersbach
committed
EAS-2675 : Detect asApp() and asUser() calls through non-default imports
1 parent 9b2b017 commit 9c78adc

File tree

2 files changed

+280
-55
lines changed

2 files changed

+280
-55
lines changed

crates/forge_analyzer/src/definitions.rs

+89-55
Original file line numberDiff line numberDiff line change
@@ -654,7 +654,7 @@ enum PropPath {
654654
Static(JsWord),
655655
MemberCall(JsWord),
656656
Unknown(Id),
657-
Expr,
657+
Expr(Option<Expr>),
658658
This,
659659
Super,
660660
Private(Id),
@@ -724,7 +724,7 @@ fn normalize_callee_expr(
724724
| Lit::Num(_)
725725
| Lit::BigInt(_)
726726
| Lit::Regex(_)
727-
| Lit::JSXText(_) => self.path.push(PropPath::Expr),
727+
| Lit::JSXText(_) => self.path.push(PropPath::Expr(None)),
728728
}
729729
}
730730

@@ -746,7 +746,7 @@ fn normalize_callee_expr(
746746
}
747747
Expr::Call(CallExpr { callee, .. }) => {
748748
let Some(expr) = callee.as_expr() else {
749-
self.path.push(PropPath::Expr);
749+
self.path.push(PropPath::Expr(Some(n.clone())));
750750
return;
751751
};
752752
match &**expr {
@@ -759,12 +759,12 @@ fn normalize_callee_expr(
759759
self.path.push(PropPath::MemberCall(ident.sym.clone()));
760760
}
761761
_ => {
762-
self.path.push(PropPath::Expr);
762+
self.path.push(PropPath::Expr(Some(n.clone())));
763763
}
764764
}
765765
}
766766

767-
_ => self.path.push(PropPath::Expr),
767+
_ => self.path.push(PropPath::Expr(Some(n.clone()))),
768768
}
769769
}
770770
}
@@ -1026,65 +1026,84 @@ impl FunctionAnalyzer<'_> {
10261026
}
10271027
}
10281028

1029+
fn get_intrinsic(
1030+
first_arg: Option<&Expr>,
1031+
is_as_app: bool,
1032+
last: &Atom,
1033+
) -> Option<Intrinsic> {
1034+
let first_arg = first_arg?;
1035+
1036+
let function_name = if *last == "requestJira" {
1037+
// Resolve Jira API requests to either JSM/JS/Jira as all are bundled within requestJira()
1038+
match first_arg {
1039+
Expr::TaggedTpl(TaggedTpl { tpl, .. }) => {
1040+
tpl.quasis.first().map(|elem| &elem.raw)
1041+
}
1042+
Expr::Lit(Lit::Str(str_lit)) => Some(&str_lit.value),
1043+
_ => None,
1044+
}
1045+
.and_then(|atom| resolve_jira_api_type(atom.as_ref()))
1046+
.unwrap_or_else(|| {
1047+
warn!("Could not resolve Jira API type, falling back to any Jira request");
1048+
IntrinsicName::RequestJiraAny
1049+
})
1050+
} else if *last == "requestBitbucket" {
1051+
IntrinsicName::RequestBitbucket
1052+
} else {
1053+
IntrinsicName::RequestConfluence
1054+
};
1055+
1056+
match classify_api_call(first_arg) {
1057+
ApiCallKind::Unknown => {
1058+
if is_as_app {
1059+
Some(Intrinsic::ApiCall(function_name))
1060+
} else {
1061+
Some(Intrinsic::SafeCall(function_name))
1062+
}
1063+
}
1064+
ApiCallKind::CustomField => {
1065+
if is_as_app {
1066+
Some(Intrinsic::ApiCustomField)
1067+
} else {
1068+
Some(Intrinsic::SafeCall(function_name))
1069+
}
1070+
}
1071+
ApiCallKind::Fields => {
1072+
if is_as_app {
1073+
Some(Intrinsic::ApiCustomField)
1074+
} else {
1075+
Some(Intrinsic::UserFieldAccess)
1076+
}
1077+
}
1078+
ApiCallKind::Trivial => Some(Intrinsic::SafeCall(function_name)),
1079+
ApiCallKind::Authorize => Some(Intrinsic::Authorize(function_name)),
1080+
}
1081+
}
1082+
10291083
match *callee {
10301084
[PropPath::Unknown((ref name, ..))] if *name == *"fetch" => Some(Intrinsic::Fetch),
1085+
[PropPath::Expr(ref n@ Some(ref expr)), PropPath::Static(ref last)] => {
1086+
if self.res.is_expr_imported_from(expr, self.module).is_some_and(
1087+
|imp| matches!(imp, ImportKind::Named(s) if *s == *"asApp" || *s == *"asUser")) {
1088+
let is_as_app = self.res.is_expr_imported_from(expr, self.module).is_some_and(
1089+
|imp| matches!(imp, ImportKind::Named(s) if *s == *"asApp"));
1090+
return get_intrinsic(first_arg, is_as_app, last);
1091+
}
1092+
None
1093+
}
10311094
[PropPath::Def(def), ref authn @ .., PropPath::Static(ref last)]
1032-
if (*last == *"requestJira"
1095+
if ((*last == *"requestJira"
10331096
|| *last == *"requestConfluence"
10341097
|| *last == *"requestBitbucket")
10351098
&& Some(&ImportKind::Default)
1036-
== self.res.is_imported_from(def, "@forge/api") =>
1099+
== self.res.is_imported_from(def, "@forge/api")) || self.res.is_imported_from(def, "@forge/api").is_some_and(
1100+
|imp| matches!(imp, ImportKind::Named(s) if *s == *"asApp" || *s == *"asUser"),
1101+
) =>
10371102
{
1038-
let first_arg = first_arg?;
10391103
let is_as_app = authn.first() == Some(&PropPath::MemberCall("asApp".into()));
1040-
1041-
let function_name = if *last == "requestJira" {
1042-
// Resolve Jira API requests to either JSM/JS/Jira as all are bundled within requestJira()
1043-
match first_arg {
1044-
Expr::TaggedTpl(TaggedTpl { tpl, .. }) => {
1045-
tpl.quasis.first().map(|elem| &elem.raw)
1046-
}
1047-
Expr::Lit(Lit::Str(str_lit)) => Some(&str_lit.value),
1048-
_ => None,
1049-
}
1050-
.and_then(|atom| resolve_jira_api_type(atom.as_ref()))
1051-
.unwrap_or_else(|| {
1052-
warn!("Could not resolve Jira API type, falling back to any Jira request");
1053-
IntrinsicName::RequestJiraAny
1054-
})
1055-
} else if *last == "requestBitbucket" {
1056-
IntrinsicName::RequestBitbucket
1057-
} else {
1058-
IntrinsicName::RequestConfluence
1059-
};
1060-
1061-
match classify_api_call(first_arg) {
1062-
ApiCallKind::Unknown => {
1063-
if is_as_app {
1064-
Some(Intrinsic::ApiCall(function_name))
1065-
} else {
1066-
Some(Intrinsic::SafeCall(function_name))
1067-
}
1068-
}
1069-
ApiCallKind::CustomField => {
1070-
if is_as_app {
1071-
Some(Intrinsic::ApiCustomField)
1072-
} else {
1073-
Some(Intrinsic::SafeCall(function_name))
1074-
}
1075-
}
1076-
ApiCallKind::Fields => {
1077-
if is_as_app {
1078-
Some(Intrinsic::ApiCustomField)
1079-
} else {
1080-
Some(Intrinsic::UserFieldAccess)
1081-
}
1082-
}
1083-
ApiCallKind::Trivial => Some(Intrinsic::SafeCall(function_name)),
1084-
ApiCallKind::Authorize => Some(Intrinsic::Authorize(function_name)),
1085-
}
1104+
let is_as_app = authn.first() == Some(&PropPath::MemberCall("asApp".into()));
1105+
get_intrinsic(first_arg, is_as_app, last)
10861106
}
1087-
10881107
[PropPath::Def(def), PropPath::Static(ref s), ..] if is_storage_read(s) => {
10891108
match self.res.is_imported_from(def, "@forge/api") {
10901109
Some(ImportKind::Named(name)) if *name == *"storage" => {
@@ -3875,6 +3894,21 @@ impl Environment {
38753894
}
38763895
}
38773896

3897+
pub fn is_expr_imported_from(&self, n: &Expr, module: ModId) -> Option<&ImportKind> {
3898+
if let Expr::Call(CallExpr {
3899+
callee: Callee::Expr(expr),
3900+
..
3901+
}) = n
3902+
{
3903+
if let Expr::Ident(id) = &**expr {
3904+
if let Some(defid) = self.recent_sym(id.sym.clone(), module) {
3905+
return self.is_imported_from(defid, "@forge/api");
3906+
}
3907+
}
3908+
}
3909+
None
3910+
}
3911+
38783912
pub fn resolve_alias(&self, def: DefId) -> DefId {
38793913
match self.def_ref(def) {
38803914
DefKind::Arg

crates/fsrt/src/test.rs

+191
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,197 @@ fn basic_authz_vuln() {
653653
assert!(scan_result.contains_vulns(1));
654654
}
655655

656+
#[test]
657+
fn basic_authz_vuln_non_default() {
658+
let test_forge_project = MockForgeProject::files_from_string(
659+
"// src/index.jsx
660+
import ForgeUI, { render, Macro, Fragment, Text } from '@forge/ui';
661+
import { route, asApp } from '@forge/api';
662+
663+
664+
function getText({ text }) {
665+
asApp().requestJira(route`/rest/api/3/issue`);
666+
return 'Hello, world!\n' + text;
667+
}
668+
669+
function App() {
670+
671+
getText({ text: 'test' })
672+
673+
return (
674+
<Fragment>
675+
<Text>Hello world!</Text>
676+
</Fragment>
677+
);
678+
}
679+
680+
export const run = render(<Macro app={<App />} />);
681+
682+
// manifest.yaml
683+
modules:
684+
macro:
685+
- key: basic-hello-world
686+
function: main
687+
title: basic
688+
handler: nothing
689+
description: Inserts Hello world!
690+
function:
691+
- key: main
692+
handler: index.run
693+
app:
694+
id: ari:cloud:ecosystem::app/07b89c0f-949a-4905-9de9-6c9521035986
695+
permissions:
696+
scopes: []",
697+
);
698+
699+
let scan_result = scan_directory_test(test_forge_project);
700+
assert!(scan_result.contains_authz_vuln(1));
701+
assert!(scan_result.contains_vulns(1));
702+
}
703+
704+
#[test]
705+
fn basic_authz_vuln_non_default_renamed() {
706+
let test_forge_project = MockForgeProject::files_from_string(
707+
"// src/index.jsx
708+
import ForgeUI, { render, Macro, Fragment, Text } from '@forge/ui';
709+
import { route, asApp as pineapple } from '@forge/api';
710+
711+
712+
function getText({ text }) {
713+
pineapple().requestJira(route`/rest/api/3/issue`);
714+
return 'Hello, world!\n' + text;
715+
}
716+
717+
function App() {
718+
719+
getText({ text: 'test' })
720+
721+
return (
722+
<Fragment>
723+
<Text>Hello world!</Text>
724+
</Fragment>
725+
);
726+
}
727+
728+
export const run = render(<Macro app={<App />} />);
729+
730+
// manifest.yaml
731+
modules:
732+
macro:
733+
- key: basic-hello-world
734+
function: main
735+
title: basic
736+
handler: nothing
737+
description: Inserts Hello world!
738+
function:
739+
- key: main
740+
handler: index.run
741+
app:
742+
id: ari:cloud:ecosystem::app/07b89c0f-949a-4905-9de9-6c9521035986
743+
permissions:
744+
scopes: []",
745+
);
746+
747+
let scan_result = scan_directory_test(test_forge_project);
748+
assert!(scan_result.contains_authz_vuln(1));
749+
assert!(scan_result.contains_vulns(1));
750+
}
751+
752+
#[test]
753+
fn basic_authz_vuln_default_and_renamed_and() {
754+
let test_forge_project = MockForgeProject::files_from_string(
755+
"// src/index.jsx
756+
import ForgeUI, { render, Macro, Fragment, Text } from '@forge/ui';
757+
import api, {route, asApp as pineapple } from '@forge/api';
758+
759+
760+
function getText({ text }) {
761+
api.asApp().requestJira(route`/rest/api/3/issue`);
762+
return 'Hello, world!\n' + text;
763+
}
764+
765+
function App() {
766+
767+
getText({ text: 'test' })
768+
769+
return (
770+
<Fragment>
771+
<Text>Hello world!</Text>
772+
</Fragment>
773+
);
774+
}
775+
776+
export const run = render(<Macro app={<App />} />);
777+
778+
// manifest.yaml
779+
modules:
780+
macro:
781+
- key: basic-hello-world
782+
function: main
783+
title: basic
784+
handler: nothing
785+
description: Inserts Hello world!
786+
function:
787+
- key: main
788+
handler: index.run
789+
app:
790+
id: ari:cloud:ecosystem::app/07b89c0f-949a-4905-9de9-6c9521035986
791+
permissions:
792+
scopes: []",
793+
);
794+
795+
let scan_result = scan_directory_test(test_forge_project);
796+
assert!(scan_result.contains_authz_vuln(1));
797+
assert!(scan_result.contains_vulns(1));
798+
}
799+
800+
#[test]
801+
fn basic_false_authz_vuln_renamed() {
802+
let test_forge_project = MockForgeProject::files_from_string(
803+
"// src/index.jsx
804+
import ForgeUI, { render, Macro, Fragment, Text } from '@forge/ui';
805+
import { route, asApp as pineapple } from '@forge/api';
806+
807+
808+
function getText({ text }) {
809+
asApp().requestJira(route`/rest/api/3/issue`);
810+
return 'Hello, world!\n' + text;
811+
}
812+
813+
function App() {
814+
815+
getText({ text: 'test' })
816+
817+
return (
818+
<Fragment>
819+
<Text>Hello world!</Text>
820+
</Fragment>
821+
);
822+
}
823+
824+
export const run = render(<Macro app={<App />} />);
825+
826+
// manifest.yaml
827+
modules:
828+
macro:
829+
- key: basic-hello-world
830+
function: main
831+
title: basic
832+
handler: nothing
833+
description: Inserts Hello world!
834+
function:
835+
- key: main
836+
handler: index.run
837+
app:
838+
id: ari:cloud:ecosystem::app/07b89c0f-949a-4905-9de9-6c9521035986
839+
permissions:
840+
scopes: []",
841+
);
842+
843+
let scan_result = scan_directory_test(test_forge_project);
844+
assert!(scan_result.contains_vulns(0));
845+
}
846+
656847
#[cfg(feature = "graphql_schema")]
657848
#[test]
658849
fn excess_scope() {

0 commit comments

Comments
 (0)