Skip to content

Commit

Permalink
Merge pull request #67 from atlassian-labs/feat/EAS-2675
Browse files Browse the repository at this point in the history
EAS-2675 : Detect asApp() and asUser() calls through non-default imports
  • Loading branch information
jwong101 authored Feb 25, 2025
2 parents 9cef386 + 9c78adc commit 1767ae8
Show file tree
Hide file tree
Showing 2 changed files with 280 additions and 55 deletions.
144 changes: 89 additions & 55 deletions crates/forge_analyzer/src/definitions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -654,7 +654,7 @@ enum PropPath {
Static(JsWord),
MemberCall(JsWord),
Unknown(Id),
Expr,
Expr(Option<Expr>),
This,
Super,
Private(Id),
Expand Down Expand Up @@ -724,7 +724,7 @@ fn normalize_callee_expr(
| Lit::Num(_)
| Lit::BigInt(_)
| Lit::Regex(_)
| Lit::JSXText(_) => self.path.push(PropPath::Expr),
| Lit::JSXText(_) => self.path.push(PropPath::Expr(None)),
}
}

Expand All @@ -746,7 +746,7 @@ fn normalize_callee_expr(
}
Expr::Call(CallExpr { callee, .. }) => {
let Some(expr) = callee.as_expr() else {
self.path.push(PropPath::Expr);
self.path.push(PropPath::Expr(Some(n.clone())));
return;
};
match &**expr {
Expand All @@ -759,12 +759,12 @@ fn normalize_callee_expr(
self.path.push(PropPath::MemberCall(ident.sym.clone()));
}
_ => {
self.path.push(PropPath::Expr);
self.path.push(PropPath::Expr(Some(n.clone())));
}
}
}

_ => self.path.push(PropPath::Expr),
_ => self.path.push(PropPath::Expr(Some(n.clone()))),
}
}
}
Expand Down Expand Up @@ -1026,65 +1026,84 @@ impl FunctionAnalyzer<'_> {
}
}

fn get_intrinsic(
first_arg: Option<&Expr>,
is_as_app: bool,
last: &Atom,
) -> Option<Intrinsic> {
let first_arg = first_arg?;

let function_name = if *last == "requestJira" {
// Resolve Jira API requests to either JSM/JS/Jira as all are bundled within requestJira()
match first_arg {
Expr::TaggedTpl(TaggedTpl { tpl, .. }) => {
tpl.quasis.first().map(|elem| &elem.raw)
}
Expr::Lit(Lit::Str(str_lit)) => Some(&str_lit.value),
_ => None,
}
.and_then(|atom| resolve_jira_api_type(atom.as_ref()))
.unwrap_or_else(|| {
warn!("Could not resolve Jira API type, falling back to any Jira request");
IntrinsicName::RequestJiraAny
})
} else if *last == "requestBitbucket" {
IntrinsicName::RequestBitbucket
} else {
IntrinsicName::RequestConfluence
};

match classify_api_call(first_arg) {
ApiCallKind::Unknown => {
if is_as_app {
Some(Intrinsic::ApiCall(function_name))
} else {
Some(Intrinsic::SafeCall(function_name))
}
}
ApiCallKind::CustomField => {
if is_as_app {
Some(Intrinsic::ApiCustomField)
} else {
Some(Intrinsic::SafeCall(function_name))
}
}
ApiCallKind::Fields => {
if is_as_app {
Some(Intrinsic::ApiCustomField)
} else {
Some(Intrinsic::UserFieldAccess)
}
}
ApiCallKind::Trivial => Some(Intrinsic::SafeCall(function_name)),
ApiCallKind::Authorize => Some(Intrinsic::Authorize(function_name)),
}
}

match *callee {
[PropPath::Unknown((ref name, ..))] if *name == *"fetch" => Some(Intrinsic::Fetch),
[PropPath::Expr(ref n@ Some(ref expr)), PropPath::Static(ref last)] => {
if self.res.is_expr_imported_from(expr, self.module).is_some_and(
|imp| matches!(imp, ImportKind::Named(s) if *s == *"asApp" || *s == *"asUser")) {
let is_as_app = self.res.is_expr_imported_from(expr, self.module).is_some_and(
|imp| matches!(imp, ImportKind::Named(s) if *s == *"asApp"));
return get_intrinsic(first_arg, is_as_app, last);
}
None
}
[PropPath::Def(def), ref authn @ .., PropPath::Static(ref last)]
if (*last == *"requestJira"
if ((*last == *"requestJira"
|| *last == *"requestConfluence"
|| *last == *"requestBitbucket")
&& Some(&ImportKind::Default)
== self.res.is_imported_from(def, "@forge/api") =>
== self.res.is_imported_from(def, "@forge/api")) || self.res.is_imported_from(def, "@forge/api").is_some_and(
|imp| matches!(imp, ImportKind::Named(s) if *s == *"asApp" || *s == *"asUser"),
) =>
{
let first_arg = first_arg?;
let is_as_app = authn.first() == Some(&PropPath::MemberCall("asApp".into()));

let function_name = if *last == "requestJira" {
// Resolve Jira API requests to either JSM/JS/Jira as all are bundled within requestJira()
match first_arg {
Expr::TaggedTpl(TaggedTpl { tpl, .. }) => {
tpl.quasis.first().map(|elem| &elem.raw)
}
Expr::Lit(Lit::Str(str_lit)) => Some(&str_lit.value),
_ => None,
}
.and_then(|atom| resolve_jira_api_type(atom.as_ref()))
.unwrap_or_else(|| {
warn!("Could not resolve Jira API type, falling back to any Jira request");
IntrinsicName::RequestJiraAny
})
} else if *last == "requestBitbucket" {
IntrinsicName::RequestBitbucket
} else {
IntrinsicName::RequestConfluence
};

match classify_api_call(first_arg) {
ApiCallKind::Unknown => {
if is_as_app {
Some(Intrinsic::ApiCall(function_name))
} else {
Some(Intrinsic::SafeCall(function_name))
}
}
ApiCallKind::CustomField => {
if is_as_app {
Some(Intrinsic::ApiCustomField)
} else {
Some(Intrinsic::SafeCall(function_name))
}
}
ApiCallKind::Fields => {
if is_as_app {
Some(Intrinsic::ApiCustomField)
} else {
Some(Intrinsic::UserFieldAccess)
}
}
ApiCallKind::Trivial => Some(Intrinsic::SafeCall(function_name)),
ApiCallKind::Authorize => Some(Intrinsic::Authorize(function_name)),
}
let is_as_app = authn.first() == Some(&PropPath::MemberCall("asApp".into()));
get_intrinsic(first_arg, is_as_app, last)
}

[PropPath::Def(def), PropPath::Static(ref s), ..] if is_storage_read(s) => {
match self.res.is_imported_from(def, "@forge/api") {
Some(ImportKind::Named(name)) if *name == *"storage" => {
Expand Down Expand Up @@ -3875,6 +3894,21 @@ impl Environment {
}
}

pub fn is_expr_imported_from(&self, n: &Expr, module: ModId) -> Option<&ImportKind> {
if let Expr::Call(CallExpr {
callee: Callee::Expr(expr),
..
}) = n
{
if let Expr::Ident(id) = &**expr {
if let Some(defid) = self.recent_sym(id.sym.clone(), module) {
return self.is_imported_from(defid, "@forge/api");
}
}
}
None
}

pub fn resolve_alias(&self, def: DefId) -> DefId {
match self.def_ref(def) {
DefKind::Arg
Expand Down
191 changes: 191 additions & 0 deletions crates/fsrt/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,197 @@ fn basic_authz_vuln() {
assert!(scan_result.contains_vulns(1));
}

#[test]
fn basic_authz_vuln_non_default() {
let test_forge_project = MockForgeProject::files_from_string(
"// src/index.jsx
import ForgeUI, { render, Macro, Fragment, Text } from '@forge/ui';
import { route, asApp } from '@forge/api';
function getText({ text }) {
asApp().requestJira(route`/rest/api/3/issue`);
return 'Hello, world!\n' + text;
}
function App() {
getText({ text: 'test' })
return (
<Fragment>
<Text>Hello world!</Text>
</Fragment>
);
}
export const run = render(<Macro app={<App />} />);
// manifest.yaml
modules:
macro:
- key: basic-hello-world
function: main
title: basic
handler: nothing
description: Inserts Hello world!
function:
- key: main
handler: index.run
app:
id: ari:cloud:ecosystem::app/07b89c0f-949a-4905-9de9-6c9521035986
permissions:
scopes: []",
);

let scan_result = scan_directory_test(test_forge_project);
assert!(scan_result.contains_authz_vuln(1));
assert!(scan_result.contains_vulns(1));
}

#[test]
fn basic_authz_vuln_non_default_renamed() {
let test_forge_project = MockForgeProject::files_from_string(
"// src/index.jsx
import ForgeUI, { render, Macro, Fragment, Text } from '@forge/ui';
import { route, asApp as pineapple } from '@forge/api';
function getText({ text }) {
pineapple().requestJira(route`/rest/api/3/issue`);
return 'Hello, world!\n' + text;
}
function App() {
getText({ text: 'test' })
return (
<Fragment>
<Text>Hello world!</Text>
</Fragment>
);
}
export const run = render(<Macro app={<App />} />);
// manifest.yaml
modules:
macro:
- key: basic-hello-world
function: main
title: basic
handler: nothing
description: Inserts Hello world!
function:
- key: main
handler: index.run
app:
id: ari:cloud:ecosystem::app/07b89c0f-949a-4905-9de9-6c9521035986
permissions:
scopes: []",
);

let scan_result = scan_directory_test(test_forge_project);
assert!(scan_result.contains_authz_vuln(1));
assert!(scan_result.contains_vulns(1));
}

#[test]
fn basic_authz_vuln_default_and_renamed_and() {
let test_forge_project = MockForgeProject::files_from_string(
"// src/index.jsx
import ForgeUI, { render, Macro, Fragment, Text } from '@forge/ui';
import api, {route, asApp as pineapple } from '@forge/api';
function getText({ text }) {
api.asApp().requestJira(route`/rest/api/3/issue`);
return 'Hello, world!\n' + text;
}
function App() {
getText({ text: 'test' })
return (
<Fragment>
<Text>Hello world!</Text>
</Fragment>
);
}
export const run = render(<Macro app={<App />} />);
// manifest.yaml
modules:
macro:
- key: basic-hello-world
function: main
title: basic
handler: nothing
description: Inserts Hello world!
function:
- key: main
handler: index.run
app:
id: ari:cloud:ecosystem::app/07b89c0f-949a-4905-9de9-6c9521035986
permissions:
scopes: []",
);

let scan_result = scan_directory_test(test_forge_project);
assert!(scan_result.contains_authz_vuln(1));
assert!(scan_result.contains_vulns(1));
}

#[test]
fn basic_false_authz_vuln_renamed() {
let test_forge_project = MockForgeProject::files_from_string(
"// src/index.jsx
import ForgeUI, { render, Macro, Fragment, Text } from '@forge/ui';
import { route, asApp as pineapple } from '@forge/api';
function getText({ text }) {
asApp().requestJira(route`/rest/api/3/issue`);
return 'Hello, world!\n' + text;
}
function App() {
getText({ text: 'test' })
return (
<Fragment>
<Text>Hello world!</Text>
</Fragment>
);
}
export const run = render(<Macro app={<App />} />);
// manifest.yaml
modules:
macro:
- key: basic-hello-world
function: main
title: basic
handler: nothing
description: Inserts Hello world!
function:
- key: main
handler: index.run
app:
id: ari:cloud:ecosystem::app/07b89c0f-949a-4905-9de9-6c9521035986
permissions:
scopes: []",
);

let scan_result = scan_directory_test(test_forge_project);
assert!(scan_result.contains_vulns(0));
}

#[cfg(feature = "graphql_schema")]
#[test]
fn excess_scope() {
Expand Down

0 comments on commit 1767ae8

Please sign in to comment.