Security reports for Forge apps: the clean scan that isn't (2026)
Mihai Perdum
Author
11 min readJuly 17, 2026
Key takeaways
"It's sandboxed, so SAST/SCA don't apply" is wrong: running them found a reachable HIGH in my own app.
When a vendor abandons npm, OSV-based scanners (Trivy, Grype, Dependabot, Snyk) flag a PATCHED version forever while npm audit reports clean. Lead your report with this or your credibility dies.
A Semgrep run whose ruleset failed to load returns 0 findings and exit 0, which is identical to clean code. Canary it before you believe it.
"It's inherited from the Atlassian SDK" was false: 10 of 15 findings reported fixAvailable: true.
Forge bounds the blast radius (zero egress, no infra of yours). It does not make a vulnerable parser absent.
A reviewer asked me for a technical profile, a SAST report and an SCA report on one of my Forge apps. My first instinct was the comfortable one: those artifacts don't really apply to a sandboxed Forge app. No server of mine, no network, no egress. What is SAST even supposed to find?
I nearly sent that. Then I ran the scans instead, and npm audit handed me a reachable HIGH in my own code: prototype pollution in an abandoned xlsx build, sitting directly under a function that parses user-supplied spreadsheets. If I'd argued the control away, the reviewer would have run one command and found a real bug I'd just claimed was impossible.
So the scans matter. But running them is the easy half. The half nobody writes about is that after you fix the thing, your scanner and the reviewer's scanner will report the same version differently. If they discover that before you explain it, everything else in your report gets re-read with suspicion.
This is how to produce a Forge security pack that survives someone actually reading it: the profile, the two scans, the trap that makes a clean result look like a lie, and the risks no scanner will ever mention.
Note
Prerequisites — a Forge app with a manifest.yml and a package.json, Node/npm, and Semgrep (pip install semgrep; use a venv if your system Python refuses under PEP 668).
Why doesn't "it's sandboxed" get you out of SAST and SCA?
Because the two claims are about different things, and only one of them is actually a security argument.
Forge genuinely bounds a lot. Your code runs on Atlassian's infrastructure, not yours. There is no server, VM, container or endpoint of yours in the request path, so there's no inbound surface to attack. If your manifest declares no external.fetch, the app has zero egress: no exfiltration path to any host. If inference runs through @forge/llm, there's no API key to leak and no third-party AI vendor receiving tenant content. Capability is capped by declared scopes and enforced by the platform at runtime.
That's a strong story and you should tell it. But notice what it is: an argument about blast radius. None of it makes a vulnerable parser stop being vulnerable. The bug still executes, inside the sandbox, on tenant data, against a file some user uploaded.
In my case the finding was xlsx@0.18.5 with two unfixed HIGH advisories, and the reachability was not theoretical:
src/shared/files/extractText.js
js
1import*asXLSXfrom"xlsx";23// ...called from the AI's file-read tool, on bytes from a user-uploaded attachment4const wb =XLSX.read(toBuffer(buffer),{type:"buffer"});5for(const name of wb.SheetNames){6const csv =XLSX.utils.sheet_to_csv(wb.Sheets[name]);7}
npm audit tells you a package is vulnerable. It never tells you whether your code reaches the sink. That's the difference between a finding and a risk, and it's a five-minute grep you have to do yourself:
bash
1grep-rn"from ['\"]xlsx['\"]" src # imported at all?2grep-rn"XLSX.read(" src # is the vulnerable call reached?
Reachable. That's what turned "npm says HIGH" into "fix this today."
Why will your scanner and the reviewer's disagree about the same version?
This is the part that will bite you, and it's barely documented anywhere.
SheetJS stopped publishing to npm. The registry is frozen at 0.18.5, permanently. Maintained builds ship from the vendor's own CDN, and their docs are explicit that the CDN is the authoritative source and the stuck registry is a known registry bug. So the fix is the vendor's own documented install path:
That clears both advisories by version floor: prototype pollution was fixed in 0.19.3, the ReDoS in 0.20.2. Run npm audit and it's clean. Ship it, write the report, done.
Except run osv-scanner, Trivy, Grype, Dependabot or Snyk against the same tree and you'll still see both HIGHs, on 0.20.3.
Neither tool is broken. The GHSA records carry an affected range of [{"introduced": "0"}] with no fixed event, because GitHub has no in-ecosystem (npm) version to name as the fix. Under strict OSV semantics that reads as affected at every version, forever. The package can never be cleared in npm-ecosystem advisory data, no matter what the vendor ships elsewhere.
Two things prove it's the data model and not a glitch, and both belong in your report:
The older SheetJS Pro CVEs (2021-32012/13/14) do carry {"fixed": "0.17.0"} and correctly match neither version. Same package, same database. The records that have a fixed event behave properly.
OSV's own records still carry last_known_affected_version_range: < 0.19.3 and < 0.20.2. Both place 0.20.3 outside. OSV itself knows.
Success
Lead your report with this. Not a footnote, not an appendix. The first paragraph. The honest framing is "patched upstream, permanently un-clearable in npm-ecosystem advisory data." If you open with a clean npm audit screenshot instead, the reviewer contradicts it in one command and then re-reads every other claim you made with suspicion. Being second to raise it looks like concealment.
Caution
The wrong turn I nearly took — I almost reported "xlsx no longer flagged" as the headline. It's true, it's reproducible, and it would have blown up the moment someone pointed a different scanner at it. A true statement that predictably gets contradicted is worse than a caveat.
How do you know a zero actually means anything?
Here's a failure mode that should worry you more than it does. Semgrep fetches its rulesets from the registry. If that fetch fails, you get 0 findings and exit code 0.
That is byte-identical to the output from genuinely clean code.
So prove the scanner works before you believe it. Plant a file with unambiguous bugs and confirm the same invocation fires:
same --config flags, pointed at the canary directory.
3
Demand ≥3 findings from the canary. If it's silent, your ruleset never loaded and your zero means nothing. Don't report it.
My app scanned 107 first-party files and returned zero. The canary returned three. That's a reportable zero. The distinction is the whole point.
The same reflex saved me twice more. When I fixed xlsx by pinning a CDN tarball, the obvious fear was that npm audit simply can't evaluate a non-registry dependency, which would make "no longer flagged" an artifact of invisibility rather than a fix. So I tested it instead of assuming:
It flagged it, and flagged only the ReDoS, not the prototype pollution that 0.20.1 had already cleared. Per-advisory range matching, on a version npm has never served. npm resolves name and version from the tarball and audits by range, ignoring origin. So the clean result on 0.20.3 is real.
And the third time: file cheerfully told me a 132-page PDF was "8 pages." One tool's summary is never ground truth.
Is it really "the Atlassian SDK's problem"?
Almost certainly not, and this is the single most rejectable sentence you can put in a pack.
Split your SCA findings into what you chose and what arrived with the platform:
bash
1npm audit --omit=dev --json> sca-prod.json # the SHIPPING tree — lead with this2npm audit --json> sca-all.json # incl. build toolchain, don't conflate
My shipping tree had 16 findings. Exactly one was a library I picked (xlsx); the other 15 arrived through @forge/* and @atlaskit/* and their transitives. Tidy story. I nearly wrote "the remaining 15 are Atlassian's to patch."
It was false. Ten of the fifteen reported fixAvailable: true, and Atlassian had already shipped the fix for one of them (@forge/events 3.0.1) that I was simply pinned behind. Every @forge/* package was behind latest. The honest split: about five are structurally theirs (fixAvailable: false even on the newest SDK), and the rest I just hadn't attempted.
While you're there, check the neighbouring claim too. "We can't force transitive versions" would have been false in my repo, which already had an overrides block. The defensible version isn't can't, it's judgment: "we could force that resolution, but it's eight majors under a UI framework we don't control and I won't ship an untested override into a rendering path." That reads as rigour. "Can't" reads as a dodge, and it's falsifiable by opening one file.
What will the scanners never tell you?
This is the section that earns trust, because it's the one they can't generate.
Neither SAST nor SCA has a rule for any of the following. On my app, every one of them was more severe than the CVE that triggered the review:
Prompt injection into privileged tool-calls. Untrusted attachment text entered the LLM context in the same turn that held createIssue, updateIssueField and transitionIssue, all in one flat tool array, under write:jira-work and manage:jira-configuration, both with allowImpersonation: true, with no sanitization and no confirmation gate. Instructions planted in a spreadsheet cell can drive writes to Jira as the user. Zero egress doesn't help you. The damage is inside the tenant. For an AI Forge app, your worst risk is usually your own tool-calling, not your dependencies.
Decompression bombs. My 15MB cap bounded the download, not the expansion. The parsers each fully expand inside a 512MB function, DEFLATE reaches roughly 1000:1, and the character truncation happened after extraction completed, so it guarded nothing at parse time. I patched a library ReDoS while an architectural DoS stayed open on the same input.
Phantom dependencies.jszip was imported in my source and declared in neitherdependencies nor devDependencies. It resolved only by hoisting out of mammoth. Any SBOM built from package.json would have silently omitted a library that parses untrusted ZIP. That's a security deliverable that's incomplete by construction.
Mutable version tags. One dependency was pinned to "latest". Every unlocked install could pull different code, which is materially worse supply-chain hygiene than the CDN tarball I was busy defending.
Find these by hand. Then disclose them before the reviewer does. It feels backwards to hand someone worse news than they asked for, but it's precisely what makes your clean SAST believable. A pack containing only good news reads as marketing.
Before you claim you fixed anything
Check where the vulnerable code actually runs:
bash
1forge install list # ground truth: which environment/site has installations2forge environments list # 'last deployed' timestamps ~ms apart = registration scaffolding, not a real deploy
I was about to deploy my fix to production. forge install list returned exactly one installation, development, and production had zero. Its environment timestamps sat 2ms apart, which is app-registration scaffolding, never a real deploy. The vulnerable dependency had never been in production at all.
Deploying there would have remediated nothing, left the actually-vulnerable build running, and then told a security reviewer it was fixed.
Two more honesty notes while you're here. Local node_modules is not the shipped artifact. Verify the deployed function, because a new exports map (0.20.3 has one, 0.18.5 didn't) could fail under the bundler and forge lint doesn't bundle. And if your evidence that a sink is fixed is the upstream changelog plus a version floor, that's vendor attestation, not exploit verification. Say so in those words. A PoC that fails to fire on the known-vulnerable version has zero diagnostic power. It's a broken test, not a pass.
The framing that survives
Don't write "this resolves the security review." It invites them to find the one thing you missed, and they will.
Write this instead:
We closed the one finding a scanner could see. Here's what the scanners structurally cannot see, which we found ourselves, and here's the plan.
The reviewer's job is to find what you missed. Hand them the list yourself and the entire conversation changes. You stop being audited and start collaborating. The fastest way to be trusted about the clean parts of your report is to be the person who volunteered the ugly ones.
Key takeaways
Never argue a security control away because it's inconvenient. Running SAST/SCA on a "sandboxed" Forge app found a reachable HIGH in my own code.
When a vendor abandons npm, OSV-based scanners flag a patched version forever ([{introduced:0}] with no fixed event) while npm audit clears it. Lead your report with the mechanism, never with a clean screenshot.
Canary every scanner before you trust a zero. A ruleset that failed to load looks exactly like clean code.
Check fixAvailable before blaming the SDK. Ten of my fifteen "inherited" findings were fixable by me.
Forge bounds blast radius (no infra of yours, zero egress, enforced scopes). It does not remove bugs, and the parser still runs on tenant data.
Disclose what the scanners can't see (prompt injection into tool-calls, decompression bombs, phantom deps) before the reviewer finds it.