Closed ly3xqhl8g9 closed 2 years ago
This is an interesting idea, but probably way out of scope for TypeScript.
TypeScript has no knowledge whether something is a network call, file access or a process environment variable.
@MartinJohns The way I read it is that functions would be annotated with that information and the compiler would verify. So if you mark a function as network: false
, you couldn't call ones marked network: true
from inside it without getting an error.
It's an interesting idea, but if there's an actual security concern, this really needs to be enforced at runtime too, and TypeScript is the wrong tool for that job.
@fatcerberus Precisely, the following would throw an error:
import fetch from 'node-fetch';
function throwsError {
network: false,
} () {
fetch('https://github.com/');
}
The idea is nothing new: Deno
has file runtime permissions, Haskell
has IO functions, and probably more languages have some support. The question is if TypeScript
could support bringing the access controls to the function level and make the control really granular.
Yes, runtime permissions would always be better, since the forbidden functions could be hidden in some external object as below. Although even then the external scope access could be forbidden.TypeScript
could do this perhaps easily by limiting the access of the function to the Abstract Syntax Tree
's external ElementAccessExpression
s (image below or link).
import fs from 'node:fs';
function throwsError {
filesystem: false,
} () {
fs.createReadStream('error');
}
const externalObject = {
fs,
};
function perhapsHardToCatch {
filesystem: false,
} () {
externalObject['fs'].createReadStream('error');
}
function stillThrowsError {
exterior: false,
} () {
externalObject['fs'].createReadStream('error');
}
How does this play with external dependencies? If I have something in node_modules with
declare function iPromiseImGood { filesystem: false } (): void;
How do you know in practice if the declaration is being honest?
@Josh-Cena A naive implementation, as I see it now for the filesystem
example, would merely check for calls to node:fs
or similar.
Ultimately, since TypeScript
is a transpile-time checker, you would trust the declaration just as you trust any regular type. The same way as when you import React from 'react'
and you trust that there really is a React.createElement
function.
Your proposal is about supply chain/dependency safety. It is basically moot if you have to rely on trusting whoever is publishing the package to also publish "honest" declarations.
"Calls to node:fs
" will almost all be gone after declaration emit. So in the end I only see this proposal able to prevent your own code from hacking yourself.
@Josh-Cena However, keep in mind that the Function Granular Access
controls can also be set up by you when wrapping the external function, consider this:
import {
iPromiseImGood,
} from '@BadActor/badPackage';
function myCodebase {
filesystem: false,
} () {
iPromiseImGood();
}
In this case I would trigger the transpilation checks from my own codebase, regardless of the granular controls of the bad iPromiseImGood
function. The transpiler would look at the fully expanded AST
of the iPromiseImGood
function and ensure the granular checks are respected to the best of its ability.
The transpiler would look at the fully expanded AST of the iPromiseImGood function and ensure the granular checks are respected to the best of its ability.
The transpiler doesn't have access to the type information. You would need to take this proposal to ECMAScript instead if you want it to have actual compile-time semantics, because otherwise any transpiler would simply strip it away.
Remember that type-checking and transpilation are two steps, and the transpiler won't take any AST information into account unless encoded in ECMAScript semantics.
Also, transpilers typically don't work on node_modules. That's the job of a bundler.
@Josh-Cena By transpilation I meant the entire process when running tsc index.ts
, and yes, it would happen in parallel with the type cheking. Once TypeScript
exposes the granular controls in the declaration of each function, the bundler can take them into account, sure.
By transpilation I meant the entire process when running
tsc index.ts
tsc
never runs on node_modules—unless you actually publish your TypeScript source instead of compiled JavaScript output, and ask all users to compile it themselves, which I don't think any sane package is doing. Instead, tsc
only reads the declarations, which is why I'm asking how well it plays with declarations.
@Josh-Cena Yes, I also don't know. Feature Request
is too strong in the title, perhaps Somewhat of a Proposal
would have been more adequate.
Maybe a comments protocol with a runtime validator is the easier/better way, something along the lines of:
/*
* access:
* network: false
*/
function adder(
a: number,
b: number,
): number {
return a + b;
}
and then after tsc index.ts
you would run:
validate index.ts && node index.js
Precisely, the following would throw an error:
This would first require all packages like node-fetch
, fs
, etc. to add such annotations. I can't see this realistically happen.
@MartinJohns Not at all, it should be enough to restrict the function only within my codebase and the validator would check all the calls made from within that function respect the granular restrictions. The harder part seems to me to statically classify if a function call is a network call, a filesystem call, etc.
The harder part seems to me to statically classify if a function call is a network call, a filesystem call, etc.
That's exactly what I meant. The libraries need to provide this information.
@MartinJohns However, as pointed out about by Josh-Cena you cannot trust the libraries. The validation must be performed each time before running the code or at least after each dependencies update, doing a full-depth call-stack analysis.
So... the entire proposal is moot. Libraries only provide declaration files, the compiler doesn't see the actual source code behind it - there's nothing to run a "full-depth call-stack analysis" on. The library, e.g. node-fetch
, must provide the information that fetch()
is a network call, because TypeScript itself has no knowledge about these things.
It's very unclear how you expect this to work at all.
@MartinJohns Pretty much in the same way Deno does it, just at the function-level, not at file-level. For the network example, TypeScript
could check that no function from net
, http
, https
, etc. is called if granular access network: false
. For filesystem, no function call from fs
, and so on. You run the call-stack analysis on the source itself, yours + dependencies.
Nevertheless, as mentioned above, maybe a third-party utility checker/validator with restrictions specified as comments is the better way to go, even if having syntax sugar in the language is a better developer experience.
Deno is a runtime, and callstack analysis is also runtime. It seems you are proposing a runtime feature instead of a compile-time one?
For the network example,
TypeScript
could check that no function fromnet
,http
,https
, etc. is called if granular accessnetwork: false
. For filesystem, no function call fromfs
, and so on.
So you're suggesting to hardcode specific module names. :thumbsdown: This wouldn't work at all. The only way this could work is if any function that does NOT have this annotation is considered most restrictive, which would be a huge PITA. Otherwise you could just include a library that wraps one of these other modules, and TypeScript would have NO clue about it.
You run the call-stack analysis on the source itself, yours + dependencies.
Remember, The TypeScript compiler only sees the declaration files of libraries, it does not see the code. The compiler can't run an analysis on something it doesn't know about.
@Josh-Cena Yes, the proposal is to do the analysis statically at compile/type-check/transpile-time.
@MartinJohns The TypeScript
compiler/parser does see the code from what I understand and the SyntaxKind
would require extension in order to classify NetworkFunctionDeclaration
, FileSystemFunctionDeclaration
, etc. No module name hardcoding required.
I am not implying in the proposal that TypeScript
is already set up for this kind of analysis, hence why it is merely a proposal.
The
TypeScript
compiler/parser does see the code from what I understand
You misunderstand. We're talking about libraries. Libraries provide declaration files, not sources, which provide the information "I have a method foo", but it does provide the information what the method "foo" actually does.
This is the information we have in declaration files:
declare function foo(): void;
But for your proposal to work you'd need to know the implementation of the method, which is not available for libraries. The method may call fs
, it may not. The compiler doesn't know.
Yes. But what is stopping the compiler from following that function declaration foo
to the source file foo.js
and check the calls function foo()
makes? It's not implemented sure, but no fundamental obstacle as far as I can see. Hence what the proposal asks: is there such a fundamental obstacle?
Yes, because not many projects publish with declaration maps—even TypeScript's own go to source definition
is only a "guess".
What Josh-Cena said. And following this, we still have the question: What qualifies a "file access call"? You either have the option to hardcode module names like fs
(which is a really bad idea), or you require the library to provide the information "this call does file access" (which requires the library definitions to be updated first.)
Sure, we always deal with imperfect information. Security is never about certainty, but mitigated risk.
Yes, hardcode is clearly a no-way. I was thinking more along the lines of checking for system calls somehow à la vm.runInNewContext
.
Nevertheless, thank you for the discussion. The idea remains, perhaps for another language. I will close now.
Suggestion
🔍 Search Terms
✅ Viability Checklist
My suggestion meets these guidelines:
⭐ Suggestion
Granular controls for functions to prevent/allow access to filesystem, network, and more.
A possible interface:
A possible syntax:
where
granularAccess
is aTypeScript object
implementing theFunctionGranularAccess
interface.Due to the nature of
TypeScript
, this can be only a transpile-time feature: the resultingJavaScript
is left unchanged, and only the developer is alerted of any granular transgressions.📃 Motivating Example
Example of a simple
add
er function without any access:more examples
💻 Use Cases
Extended programs and especially programs which are intended for extension and composition such as libraries require special care in terms of security.
The
NPM
ecosystem is only growing, and so is the dependency tree of even the simplest package. Having granular access over the permissions of each function, even if only at transpile-time, would give the developer a greater chance of defending against dependency changes/attacks and also enhances the design intent behind a function definition for non-adversarial, collaborative environments.