microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
100.16k stars 12.38k forks source link

[Feature Request] Function Granular Access #49601

Closed ly3xqhl8g9 closed 2 years ago

ly3xqhl8g9 commented 2 years ago

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:

interface FunctionGranularAccess {
    network?: boolean | string | string[]; // allows network access for all (boolean), for one (string), for many (string[])
    filesystem?: boolean | string | string[]; // allows filesystem access for all (boolean), for one (string), for many (string[])
    exterior?: boolean; // allows access to functions/variables outside of the function's scope
    environment?: boolean; // allows access to process environment
    imports?: boolean; // allows the function to import dynamically
    dependencies?: string[] // specify the dependencies which can be used by the function
}

A possible syntax:

function name {granularAccess} (parameters) {body}

where granularAccess is a TypeScript object implementing the FunctionGranularAccess interface.

Due to the nature of TypeScript, this can be only a transpile-time feature: the resulting JavaScript is left unchanged, and only the developer is alerted of any granular transgressions.

📃 Motivating Example

Example of a simple adder function without any access:

/**
 * The function adds the `first` value with the `second` value.
 *
 * @param first
 * @param second
 * @returns
 */
function add {
    network: false,
    filesystem: false,
    exterior: false,
    environment: false,
    imports: false,
    dependencies: [],
} (
    first: number,
    second: number,
): number {
    return first + second;
}

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.

fatcerberus commented 2 years ago

This is an interesting idea, but probably way out of scope for TypeScript.

MartinJohns commented 2 years ago

TypeScript has no knowledge whether something is a network call, file access or a process environment variable.

fatcerberus commented 2 years ago

@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.

ly3xqhl8g9 commented 2 years ago

@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 ElementAccessExpressions (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');
}

AST

Josh-Cena commented 2 years ago

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?

ly3xqhl8g9 commented 2 years ago

@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.

Josh-Cena commented 2 years ago

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.

ly3xqhl8g9 commented 2 years ago

@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.

Josh-Cena commented 2 years ago

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.

ly3xqhl8g9 commented 2 years ago

@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.

Josh-Cena commented 2 years ago

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.

ly3xqhl8g9 commented 2 years ago

@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
MartinJohns commented 2 years ago

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.

ly3xqhl8g9 commented 2 years ago

@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.

MartinJohns commented 2 years ago

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.

ly3xqhl8g9 commented 2 years ago

@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.

MartinJohns commented 2 years ago

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.

ly3xqhl8g9 commented 2 years ago

@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.

Josh-Cena commented 2 years ago

Deno is a runtime, and callstack analysis is also runtime. It seems you are proposing a runtime feature instead of a compile-time one?

MartinJohns commented 2 years ago

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.

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.

ly3xqhl8g9 commented 2 years ago

@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.

MartinJohns commented 2 years ago

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.

ly3xqhl8g9 commented 2 years ago

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?

Josh-Cena commented 2 years ago

Yes, because not many projects publish with declaration maps—even TypeScript's own go to source definition is only a "guess".

MartinJohns commented 2 years ago

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.)

ly3xqhl8g9 commented 2 years ago

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.