@rigour-labs/core 6.2.4 → 6.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/deep/code-context.d.ts +28 -0
- package/dist/deep/code-context.js +93 -0
- package/dist/deep/code-context.test.d.ts +1 -0
- package/dist/deep/code-context.test.js +48 -0
- package/dist/deep/code-pass.d.ts +15 -0
- package/dist/deep/code-pass.js +44 -0
- package/dist/deep/code-review-prompt.d.ts +17 -0
- package/dist/deep/code-review-prompt.js +30 -0
- package/dist/deep/code-review-prompt.test.d.ts +1 -0
- package/dist/deep/code-review-prompt.test.js +20 -0
- package/dist/deep/code-verifier.d.ts +19 -0
- package/dist/deep/code-verifier.js +48 -0
- package/dist/deep/code-verifier.test.d.ts +1 -0
- package/dist/deep/code-verifier.test.js +46 -0
- package/dist/deep/fact-extractor.d.ts +7 -1
- package/dist/deep/fact-extractor.js +16 -8
- package/dist/deep/fact-extractor.test.js +34 -1
- package/dist/deep/facts-pass.d.ts +24 -0
- package/dist/deep/facts-pass.js +69 -0
- package/dist/deep/index.d.ts +6 -1
- package/dist/deep/index.js +5 -1
- package/dist/deep/parse-findings.d.ts +12 -0
- package/dist/deep/parse-findings.js +73 -0
- package/dist/deep/parse-findings.test.d.ts +1 -0
- package/dist/deep/parse-findings.test.js +25 -0
- package/dist/deep/prompts.d.ts +1 -1
- package/dist/deep/prompts.js +7 -8
- package/dist/deep/prompts.test.js +15 -0
- package/dist/discovery-signals.d.ts +27 -0
- package/dist/discovery-signals.js +96 -0
- package/dist/discovery.d.ts +1 -2
- package/dist/discovery.js +36 -49
- package/dist/discovery.test.js +84 -71
- package/dist/gates/context.js +7 -0
- package/dist/gates/deep-analysis.d.ts +33 -8
- package/dist/gates/deep-analysis.js +169 -306
- package/dist/gates/deep-analysis.test.d.ts +1 -0
- package/dist/gates/deep-analysis.test.js +86 -0
- package/dist/gates/deep-intent.d.ts +3 -0
- package/dist/gates/deep-intent.js +57 -0
- package/dist/gates/deep-intent.test.d.ts +1 -0
- package/dist/gates/deep-intent.test.js +51 -0
- package/dist/gates/deep-runner.d.ts +15 -0
- package/dist/gates/deep-runner.js +66 -0
- package/dist/gates/dependency.js +4 -0
- package/dist/gates/deprecated-apis-ast.d.ts +8 -0
- package/dist/gates/deprecated-apis-ast.js +25 -0
- package/dist/gates/deprecated-apis-rules-node.js +2 -8
- package/dist/gates/deprecated-apis.js +5 -1
- package/dist/gates/deprecated-apis.test.js +30 -0
- package/dist/gates/frontend-secret-exposure.js +5 -1
- package/dist/gates/frontend-secret-exposure.test.js +20 -0
- package/dist/gates/hallucinated-imports/framework-modules.d.ts +14 -0
- package/dist/gates/hallucinated-imports/framework-modules.js +75 -0
- package/dist/gates/hallucinated-imports/index.js +14 -6
- package/dist/gates/hallucinated-imports/js-resolver.d.ts +16 -12
- package/dist/gates/hallucinated-imports/js-resolver.js +65 -153
- package/dist/gates/hallucinated-imports/ts-path-target.js +16 -0
- package/dist/gates/hallucinated-imports/tsconfig-paths.d.ts +16 -0
- package/dist/gates/hallucinated-imports/tsconfig-paths.js +120 -0
- package/dist/gates/hallucinated-imports-sveltekit.test.d.ts +1 -0
- package/dist/gates/hallucinated-imports-sveltekit.test.js +108 -0
- package/dist/gates/logic-drift.js +7 -1
- package/dist/gates/runner.js +10 -40
- package/dist/gates/runner.test.js +37 -25
- package/dist/gates/scoped-gates.test.d.ts +1 -0
- package/dist/gates/scoped-gates.test.js +52 -0
- package/dist/gates/semantic-bugs.d.ts +23 -0
- package/dist/gates/semantic-bugs.js +48 -0
- package/dist/gates/semantic-bugs.test.d.ts +1 -0
- package/dist/gates/semantic-bugs.test.js +70 -0
- package/dist/gates/side-effect-analysis/index.js +11 -1
- package/dist/gates/side-effect-analysis.test.d.ts +1 -0
- package/dist/gates/side-effect-analysis.test.js +162 -0
- package/dist/gates/side-effect-helpers/pattern-detection.js +35 -7
- package/dist/gates/side-effect-helpers/resource-tracking.js +17 -10
- package/dist/gates/side-effect-helpers/scope-analysis.d.ts +6 -0
- package/dist/gates/side-effect-helpers/scope-analysis.js +20 -0
- package/dist/gates/style-drift.js +3 -0
- package/dist/index.d.ts +11 -2
- package/dist/index.js +10 -2
- package/dist/inference/http-download.d.ts +22 -0
- package/dist/inference/http-download.js +132 -0
- package/dist/inference/http-download.test.d.ts +1 -0
- package/dist/inference/http-download.test.js +109 -0
- package/dist/inference/index.d.ts +5 -1
- package/dist/inference/index.js +2 -1
- package/dist/inference/llama-engine.d.ts +27 -0
- package/dist/inference/llama-engine.js +152 -0
- package/dist/inference/llama-engine.test.d.ts +1 -0
- package/dist/inference/llama-engine.test.js +51 -0
- package/dist/inference/llama-process.d.ts +72 -0
- package/dist/inference/llama-process.js +126 -0
- package/dist/inference/llama-process.test.d.ts +1 -0
- package/dist/inference/llama-process.test.js +61 -0
- package/dist/inference/model-download.test.d.ts +1 -0
- package/dist/inference/model-download.test.js +73 -0
- package/dist/inference/model-manager.d.ts +14 -1
- package/dist/inference/model-manager.js +65 -80
- package/dist/inference/sidecar-provider.d.ts +19 -10
- package/dist/inference/sidecar-provider.js +125 -342
- package/dist/inference/types.d.ts +2 -0
- package/dist/semantic/ast.d.ts +29 -0
- package/dist/semantic/ast.js +122 -0
- package/dist/semantic/benchmark.d.ts +16 -0
- package/dist/semantic/benchmark.js +31 -0
- package/dist/semantic/benchmark.test.d.ts +1 -0
- package/dist/semantic/benchmark.test.js +18 -0
- package/dist/semantic/engine.d.ts +8 -0
- package/dist/semantic/engine.js +45 -0
- package/dist/semantic/intent/benchmark.d.ts +31 -0
- package/dist/semantic/intent/benchmark.js +74 -0
- package/dist/semantic/intent/intent-check.d.ts +43 -0
- package/dist/semantic/intent/intent-check.js +72 -0
- package/dist/semantic/intent/intent.test.d.ts +1 -0
- package/dist/semantic/intent/intent.test.js +46 -0
- package/dist/semantic/intent/parallel-reads.d.ts +23 -0
- package/dist/semantic/intent/parallel-reads.js +59 -0
- package/dist/semantic/learn/benchmark.d.ts +20 -0
- package/dist/semantic/learn/benchmark.js +71 -0
- package/dist/semantic/learn/compile.d.ts +4 -0
- package/dist/semantic/learn/compile.js +56 -0
- package/dist/semantic/learn/conditions.d.ts +22 -0
- package/dist/semantic/learn/conditions.js +93 -0
- package/dist/semantic/learn/extract.d.ts +30 -0
- package/dist/semantic/learn/extract.js +122 -0
- package/dist/semantic/learn/git-trees.d.ts +8 -0
- package/dist/semantic/learn/git-trees.js +32 -0
- package/dist/semantic/learn/identity.d.ts +18 -0
- package/dist/semantic/learn/identity.js +97 -0
- package/dist/semantic/learn/learn.d.ts +25 -0
- package/dist/semantic/learn/learn.js +109 -0
- package/dist/semantic/learn/learn.test.d.ts +1 -0
- package/dist/semantic/learn/learn.test.js +67 -0
- package/dist/semantic/learn/store.d.ts +4 -0
- package/dist/semantic/learn/store.js +39 -0
- package/dist/semantic/learn/types.d.ts +468 -0
- package/dist/semantic/learn/types.js +55 -0
- package/dist/semantic/origins.d.ts +42 -0
- package/dist/semantic/origins.js +149 -0
- package/dist/semantic/origins.test.d.ts +1 -0
- package/dist/semantic/origins.test.js +77 -0
- package/dist/semantic/program.d.ts +13 -0
- package/dist/semantic/program.js +62 -0
- package/dist/semantic/rules/credential-redirect.d.ts +2 -0
- package/dist/semantic/rules/credential-redirect.js +209 -0
- package/dist/semantic/rules/degraded-response-cached.d.ts +2 -0
- package/dist/semantic/rules/degraded-response-cached.js +271 -0
- package/dist/semantic/rules/in-memory-aggregation.d.ts +2 -0
- package/dist/semantic/rules/in-memory-aggregation.js +179 -0
- package/dist/semantic/summaries.d.ts +18 -0
- package/dist/semantic/summaries.js +33 -0
- package/dist/semantic/types.d.ts +32 -0
- package/dist/semantic/types.js +1 -0
- package/dist/storage/index.d.ts +1 -0
- package/dist/storage/index.js +1 -0
- package/dist/storage/team-diagnostics.d.ts +22 -0
- package/dist/storage/team-diagnostics.js +58 -0
- package/dist/storage/team-diagnostics.test.d.ts +1 -0
- package/dist/storage/team-diagnostics.test.js +51 -0
- package/dist/storage/team-schema.d.ts +6 -0
- package/dist/storage/team-schema.js +82 -0
- package/dist/storage/team-store.js +25 -72
- package/dist/templates/universal-config.js +4 -2
- package/dist/types/index.d.ts +114 -14
- package/dist/types/index.js +16 -2
- package/dist/utils/deep-status.d.ts +7 -0
- package/dist/utils/deep-status.js +11 -0
- package/dist/utils/diff.d.ts +3 -0
- package/dist/utils/diff.js +52 -0
- package/dist/utils/diff.test.d.ts +1 -0
- package/dist/utils/diff.test.js +38 -0
- package/dist/utils/scanner.js +13 -2
- package/dist/utils/scanner.test.js +22 -8
- package/dist/utils/scope.d.ts +15 -0
- package/dist/utils/scope.js +51 -0
- package/dist/utils/scope.test.d.ts +1 -0
- package/dist/utils/scope.test.js +38 -0
- package/package.json +6 -6
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rules learned from a fix: a before/after pair turned into a check.
|
|
3
|
+
*
|
|
4
|
+
* A learned rule is data (`.rigour/rules/<id>.json`), reviewed in pull
|
|
5
|
+
* requests like code. It is kept only when it fires on the code before the
|
|
6
|
+
* fix, is silent on the fixed code, and fires on few other places in the
|
|
7
|
+
* repository; the validation that proved this is stored with it.
|
|
8
|
+
*/
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
/** How a call is recognised: by its name, the declaration it resolves to, or its exact text. */
|
|
11
|
+
export const CalleeMatcherSchema = z.object({
|
|
12
|
+
level: z.enum(['name', 'declaration', 'text']),
|
|
13
|
+
key: z.string().min(1),
|
|
14
|
+
});
|
|
15
|
+
/** Narrows a rule to one function in one file: a regression guard for the fixed code. */
|
|
16
|
+
export const ScopeSchema = z.object({
|
|
17
|
+
file: z.string().min(1),
|
|
18
|
+
fn: z.string().min(1),
|
|
19
|
+
});
|
|
20
|
+
export const RequireOptionSchema = z.object({
|
|
21
|
+
template: z.literal('require-option'),
|
|
22
|
+
callee: CalleeMatcherSchema,
|
|
23
|
+
argIndex: z.number().int().min(0),
|
|
24
|
+
property: z.string().min(1),
|
|
25
|
+
value: z.string().optional(),
|
|
26
|
+
scope: ScopeSchema.optional(),
|
|
27
|
+
});
|
|
28
|
+
export const RequireGuardSchema = z.object({
|
|
29
|
+
template: z.literal('require-guard'),
|
|
30
|
+
callee: CalleeMatcherSchema,
|
|
31
|
+
property: z.string().min(1),
|
|
32
|
+
guard: z.string().min(1),
|
|
33
|
+
/** Apply only where the call's other arguments mention `guard` (e.g. the body carries it). */
|
|
34
|
+
appliesWhenArgsMention: z.boolean(),
|
|
35
|
+
scope: ScopeSchema.optional(),
|
|
36
|
+
});
|
|
37
|
+
export const LearnedPatternSchema = z.discriminatedUnion('template', [RequireOptionSchema, RequireGuardSchema]);
|
|
38
|
+
export const LearnedRuleSchema = z.object({
|
|
39
|
+
id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
|
|
40
|
+
version: z.literal(1),
|
|
41
|
+
pattern: LearnedPatternSchema,
|
|
42
|
+
message: z.string(),
|
|
43
|
+
severity: z.enum(['low', 'medium', 'high']),
|
|
44
|
+
source: z.object({
|
|
45
|
+
commit: z.string().optional(),
|
|
46
|
+
file: z.string(),
|
|
47
|
+
function: z.string(),
|
|
48
|
+
}),
|
|
49
|
+
validation: z.object({
|
|
50
|
+
firesBefore: z.number().int(),
|
|
51
|
+
firesAfter: z.number().int(),
|
|
52
|
+
maxHits: z.number().int(),
|
|
53
|
+
repoHits: z.array(z.string()),
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where can an expression's value come from?
|
|
3
|
+
*
|
|
4
|
+
* Traces identifiers back through declarations (const/let initializers and
|
|
5
|
+
* reassignments), destructuring (including an element of `Promise.all([...])`),
|
|
6
|
+
* conditionals and `??`/`||`, using checker symbols rather than names. The
|
|
7
|
+
* result is a set of origins: a call, a parameter, an object literal, another
|
|
8
|
+
* literal, or unknown. Unknown origins are kept so rules can stay silent.
|
|
9
|
+
*/
|
|
10
|
+
import ts from 'typescript';
|
|
11
|
+
import { type FunctionLike } from './ast.js';
|
|
12
|
+
export type Origin = {
|
|
13
|
+
kind: 'call';
|
|
14
|
+
node: ts.CallExpression;
|
|
15
|
+
} | {
|
|
16
|
+
kind: 'param';
|
|
17
|
+
fn: FunctionLike;
|
|
18
|
+
index: number;
|
|
19
|
+
node: ts.ParameterDeclaration;
|
|
20
|
+
} | {
|
|
21
|
+
kind: 'object';
|
|
22
|
+
node: ts.ObjectLiteralExpression;
|
|
23
|
+
} | {
|
|
24
|
+
kind: 'literal';
|
|
25
|
+
node: ts.Expression;
|
|
26
|
+
} | {
|
|
27
|
+
kind: 'unknown';
|
|
28
|
+
node: ts.Node;
|
|
29
|
+
};
|
|
30
|
+
export declare function originsOf(checker: ts.TypeChecker, expr: ts.Expression, depth?: number, seen?: Set<ts.Node>): Origin[];
|
|
31
|
+
/** Elements of `Promise.all([...])` / `Promise.allSettled([...])`, else undefined. */
|
|
32
|
+
export declare function promiseAllItems(expr: ts.Expression): ts.NodeArray<ts.Expression> | undefined;
|
|
33
|
+
export interface PropertyValues {
|
|
34
|
+
/** Direct values for the property. */
|
|
35
|
+
values: ts.Expression[];
|
|
36
|
+
/** Spreads whose object could not be resolved; the property may come from them. */
|
|
37
|
+
unresolvedSpreads: ts.Expression[];
|
|
38
|
+
/** Spread expressions that were resolved, in source order. */
|
|
39
|
+
spreads: ts.Expression[];
|
|
40
|
+
}
|
|
41
|
+
/** Values an object literal gives to `key`, following `...spread` of other object literals. */
|
|
42
|
+
export declare function propertyValues(checker: ts.TypeChecker, obj: ts.ObjectLiteralExpression, key: string, depth?: number): PropertyValues;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where can an expression's value come from?
|
|
3
|
+
*
|
|
4
|
+
* Traces identifiers back through declarations (const/let initializers and
|
|
5
|
+
* reassignments), destructuring (including an element of `Promise.all([...])`),
|
|
6
|
+
* conditionals and `??`/`||`, using checker symbols rather than names. The
|
|
7
|
+
* result is a set of origins: a call, a parameter, an object literal, another
|
|
8
|
+
* literal, or unknown. Unknown origins are kept so rules can stay silent.
|
|
9
|
+
*/
|
|
10
|
+
import ts from 'typescript';
|
|
11
|
+
import { enclosingFunction, forEachOwnNode, isFunctionLike, propertyNameText, symbolOf, unwrap } from './ast.js';
|
|
12
|
+
const MAX_DEPTH = 8;
|
|
13
|
+
export function originsOf(checker, expr, depth = 0, seen = new Set()) {
|
|
14
|
+
const node = unwrap(expr);
|
|
15
|
+
if (depth > MAX_DEPTH || seen.has(node))
|
|
16
|
+
return [{ kind: 'unknown', node }];
|
|
17
|
+
seen.add(node);
|
|
18
|
+
if (ts.isCallExpression(node))
|
|
19
|
+
return [{ kind: 'call', node }];
|
|
20
|
+
if (ts.isObjectLiteralExpression(node))
|
|
21
|
+
return [{ kind: 'object', node }];
|
|
22
|
+
if (ts.isConditionalExpression(node)) {
|
|
23
|
+
return [...originsOf(checker, node.whenTrue, depth + 1, seen), ...originsOf(checker, node.whenFalse, depth + 1, seen)];
|
|
24
|
+
}
|
|
25
|
+
if (ts.isBinaryExpression(node) && isFallbackOperator(node.operatorToken.kind)) {
|
|
26
|
+
return [...originsOf(checker, node.left, depth + 1, seen), ...originsOf(checker, node.right, depth + 1, seen)];
|
|
27
|
+
}
|
|
28
|
+
if (isPlainLiteral(node))
|
|
29
|
+
return [{ kind: 'literal', node }];
|
|
30
|
+
if (ts.isIdentifier(node))
|
|
31
|
+
return identifierOrigins(checker, node, depth, seen);
|
|
32
|
+
return [{ kind: 'unknown', node }];
|
|
33
|
+
}
|
|
34
|
+
function isFallbackOperator(kind) {
|
|
35
|
+
return kind === ts.SyntaxKind.QuestionQuestionToken || kind === ts.SyntaxKind.BarBarToken;
|
|
36
|
+
}
|
|
37
|
+
function isPlainLiteral(node) {
|
|
38
|
+
return ts.isStringLiteralLike(node) || ts.isNumericLiteral(node) || ts.isArrayLiteralExpression(node)
|
|
39
|
+
|| node.kind === ts.SyntaxKind.NullKeyword || node.kind === ts.SyntaxKind.TrueKeyword
|
|
40
|
+
|| node.kind === ts.SyntaxKind.FalseKeyword || ts.isTemplateExpression(node)
|
|
41
|
+
|| (ts.isIdentifier(node) && node.text === 'undefined');
|
|
42
|
+
}
|
|
43
|
+
function identifierOrigins(checker, id, depth, seen) {
|
|
44
|
+
const decl = symbolOf(checker, id)?.valueDeclaration;
|
|
45
|
+
if (!decl)
|
|
46
|
+
return [{ kind: 'unknown', node: id }];
|
|
47
|
+
if (ts.isParameter(decl) && isFunctionLike(decl.parent)) {
|
|
48
|
+
const param = { kind: 'param', fn: decl.parent, index: decl.parent.parameters.indexOf(decl), node: decl };
|
|
49
|
+
return decl.initializer ? [param, ...originsOf(checker, decl.initializer, depth + 1, seen)] : [param];
|
|
50
|
+
}
|
|
51
|
+
if (ts.isVariableDeclaration(decl)) {
|
|
52
|
+
const fromInit = decl.initializer ? originsOf(checker, decl.initializer, depth + 1, seen) : [];
|
|
53
|
+
return [...fromInit, ...reassignmentOrigins(checker, decl, depth, seen)];
|
|
54
|
+
}
|
|
55
|
+
if (ts.isBindingElement(decl))
|
|
56
|
+
return bindingOrigins(checker, decl, depth, seen);
|
|
57
|
+
return [{ kind: 'unknown', node: decl }];
|
|
58
|
+
}
|
|
59
|
+
/** `let x = a; ... x = b;` inside the same function: both values are origins. */
|
|
60
|
+
function reassignmentOrigins(checker, decl, depth, seen) {
|
|
61
|
+
const list = decl.parent;
|
|
62
|
+
if (!ts.isVariableDeclarationList(list) || (list.flags & ts.NodeFlags.Const) || !ts.isIdentifier(decl.name))
|
|
63
|
+
return [];
|
|
64
|
+
const target = checker.getSymbolAtLocation(decl.name);
|
|
65
|
+
const scope = enclosingFunction(decl) ?? decl.getSourceFile();
|
|
66
|
+
const found = [];
|
|
67
|
+
forEachOwnNode(scope, (node) => {
|
|
68
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken
|
|
69
|
+
&& ts.isIdentifier(node.left) && checker.getSymbolAtLocation(node.left) === target) {
|
|
70
|
+
found.push(...originsOf(checker, node.right, depth + 1, seen));
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
return found;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* `const [a, b] = await Promise.all([f(), g()])` → b comes from g().
|
|
77
|
+
* `const { x } = obj` → x comes from obj.x when obj is an object literal.
|
|
78
|
+
*/
|
|
79
|
+
function bindingOrigins(checker, element, depth, seen) {
|
|
80
|
+
const pattern = element.parent;
|
|
81
|
+
const holder = pattern.parent;
|
|
82
|
+
if (!ts.isVariableDeclaration(holder) || !holder.initializer)
|
|
83
|
+
return [{ kind: 'unknown', node: element }];
|
|
84
|
+
const init = unwrap(holder.initializer);
|
|
85
|
+
if (ts.isArrayBindingPattern(pattern)) {
|
|
86
|
+
const index = pattern.elements.indexOf(element);
|
|
87
|
+
const items = promiseAllItems(init) ?? (ts.isArrayLiteralExpression(init) ? init.elements : undefined);
|
|
88
|
+
const item = items?.[index];
|
|
89
|
+
return item && !ts.isSpreadElement(item) ? originsOf(checker, item, depth + 1, seen) : [{ kind: 'unknown', node: element }];
|
|
90
|
+
}
|
|
91
|
+
const key = propertyNameText(element.propertyName) ?? (ts.isIdentifier(element.name) ? element.name.text : undefined);
|
|
92
|
+
if (!key)
|
|
93
|
+
return [{ kind: 'unknown', node: element }];
|
|
94
|
+
const values = [];
|
|
95
|
+
for (const origin of originsOf(checker, init, depth + 1, seen)) {
|
|
96
|
+
if (origin.kind !== 'object') {
|
|
97
|
+
values.push(origin);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
for (const value of propertyValues(checker, origin.node, key).values) {
|
|
101
|
+
values.push(...originsOf(checker, value, depth + 1, seen));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return values;
|
|
105
|
+
}
|
|
106
|
+
/** Elements of `Promise.all([...])` / `Promise.allSettled([...])`, else undefined. */
|
|
107
|
+
export function promiseAllItems(expr) {
|
|
108
|
+
const node = unwrap(expr);
|
|
109
|
+
if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression))
|
|
110
|
+
return undefined;
|
|
111
|
+
const target = node.expression;
|
|
112
|
+
if (!ts.isIdentifier(target.expression) || target.expression.text !== 'Promise')
|
|
113
|
+
return undefined;
|
|
114
|
+
if (target.name.text !== 'all' && target.name.text !== 'allSettled')
|
|
115
|
+
return undefined;
|
|
116
|
+
const [arg] = node.arguments;
|
|
117
|
+
return arg && ts.isArrayLiteralExpression(arg) ? arg.elements : undefined;
|
|
118
|
+
}
|
|
119
|
+
/** Values an object literal gives to `key`, following `...spread` of other object literals. */
|
|
120
|
+
export function propertyValues(checker, obj, key, depth = 0) {
|
|
121
|
+
const result = { values: [], unresolvedSpreads: [], spreads: [] };
|
|
122
|
+
const matches = (name) => name !== undefined && name.toLowerCase() === key.toLowerCase();
|
|
123
|
+
for (const prop of obj.properties) {
|
|
124
|
+
if (ts.isPropertyAssignment(prop) && matches(propertyNameText(prop.name)))
|
|
125
|
+
result.values.push(prop.initializer);
|
|
126
|
+
else if (ts.isShorthandPropertyAssignment(prop) && matches(prop.name.text))
|
|
127
|
+
result.values.push(prop.name);
|
|
128
|
+
else if (ts.isSpreadAssignment(prop))
|
|
129
|
+
collectSpread(checker, prop.expression, key, depth, result);
|
|
130
|
+
}
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
function collectSpread(checker, spread, key, depth, into) {
|
|
134
|
+
into.spreads.push(spread);
|
|
135
|
+
if (depth > MAX_DEPTH) {
|
|
136
|
+
into.unresolvedSpreads.push(spread);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
for (const origin of originsOf(checker, spread)) {
|
|
140
|
+
if (origin.kind === 'object') {
|
|
141
|
+
const nested = propertyValues(checker, origin.node, key, depth + 1);
|
|
142
|
+
into.values.push(...nested.values);
|
|
143
|
+
into.unresolvedSpreads.push(...nested.unresolvedSpreads);
|
|
144
|
+
}
|
|
145
|
+
else if (origin.kind !== 'literal') {
|
|
146
|
+
into.unresolvedSpreads.push(spread);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import ts from 'typescript';
|
|
5
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
6
|
+
import { forEachNode, symbolOf } from './ast.js';
|
|
7
|
+
import { originsOf } from './origins.js';
|
|
8
|
+
import { programBatches } from './program.js';
|
|
9
|
+
let dir;
|
|
10
|
+
afterEach(() => { if (dir)
|
|
11
|
+
fs.rmSync(dir, { recursive: true, force: true }); dir = undefined; });
|
|
12
|
+
/** Program over `files`; returns the checker and a finder for `probe(<expr>)` arguments in main.ts. */
|
|
13
|
+
function setup(files) {
|
|
14
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'origins-'));
|
|
15
|
+
for (const [name, body] of Object.entries(files))
|
|
16
|
+
fs.writeFileSync(path.join(dir, name), body);
|
|
17
|
+
const [program] = [...programBatches([path.join(dir, 'main.ts')], { strict: true, noEmit: true })];
|
|
18
|
+
const checker = program.getTypeChecker();
|
|
19
|
+
const main = program.getSourceFile(path.join(dir, 'main.ts'));
|
|
20
|
+
const probe = () => {
|
|
21
|
+
let found;
|
|
22
|
+
forEachNode(main, (n) => {
|
|
23
|
+
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression) && n.expression.text === 'probe')
|
|
24
|
+
found = n.arguments[0];
|
|
25
|
+
});
|
|
26
|
+
return found;
|
|
27
|
+
};
|
|
28
|
+
const kinds = () => originsOf(checker, probe()).map(o => o.kind === 'call' ? `call:${o.node.expression.getText()}` : o.kind === 'param' ? `param:${o.index}` : o.kind);
|
|
29
|
+
return { kinds };
|
|
30
|
+
}
|
|
31
|
+
const PRELUDE = 'declare function probe(v: unknown): void;\ndeclare function a(): number;\ndeclare function b(): number;\n';
|
|
32
|
+
describe('originsOf', () => {
|
|
33
|
+
it('follows const initializers and let reassignments', () => {
|
|
34
|
+
const { kinds } = setup({ 'main.ts': `${PRELUDE}let x = a();\nif (Math.random()) x = b();\nconst y = x;\nprobe(y);\n` });
|
|
35
|
+
expect(kinds().sort()).toEqual(['call:a', 'call:b']);
|
|
36
|
+
});
|
|
37
|
+
it('maps an element of Promise.all([...]) to the matching call', async () => {
|
|
38
|
+
const { kinds } = setup({ 'main.ts': `${PRELUDE}async function f() {\n const [first, second] = await Promise.all([a(), b()]);\n probe(second);\n return first;\n}\n` });
|
|
39
|
+
expect(kinds()).toEqual(['call:b']);
|
|
40
|
+
});
|
|
41
|
+
it('reads a destructured property from an object literal', () => {
|
|
42
|
+
const { kinds } = setup({ 'main.ts': `${PRELUDE}const obj = { left: a(), right: b() };\nconst { right } = obj;\nprobe(right);\n` });
|
|
43
|
+
expect(kinds()).toEqual(['call:b']);
|
|
44
|
+
});
|
|
45
|
+
it('keeps both sides of ?? and of a conditional', () => {
|
|
46
|
+
const { kinds } = setup({ 'main.ts': `${PRELUDE}const v = Math.random() > 0.5 ? a() : (b() ?? a());\nprobe(v);\n` });
|
|
47
|
+
expect(kinds().sort()).toEqual(['call:a', 'call:a', 'call:b']);
|
|
48
|
+
});
|
|
49
|
+
it('reports a parameter with its index', () => {
|
|
50
|
+
const { kinds } = setup({ 'main.ts': `${PRELUDE}function f(first: number, second: number) {\n probe(second);\n return first;\n}\n` });
|
|
51
|
+
expect(kinds()).toEqual(['param:1']);
|
|
52
|
+
});
|
|
53
|
+
it('follows imports across files to the declaration', () => {
|
|
54
|
+
const { kinds } = setup({
|
|
55
|
+
'lib.ts': 'export const shared = Math.max(1, 2);\n',
|
|
56
|
+
'main.ts': `${PRELUDE}import { shared } from './lib';\nprobe(shared);\n`,
|
|
57
|
+
});
|
|
58
|
+
expect(kinds()).toEqual(['call:Math.max']);
|
|
59
|
+
});
|
|
60
|
+
it('stays unknown for an import that does not resolve', () => {
|
|
61
|
+
const { kinds } = setup({ 'main.ts': `${PRELUDE}import { missing } from './nowhere';\nprobe(missing);\n` });
|
|
62
|
+
expect(kinds()).toEqual(['unknown']);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
describe('symbolOf', () => {
|
|
66
|
+
it('resolves the name in a shorthand property to the variable it reads', () => {
|
|
67
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'origins-'));
|
|
68
|
+
fs.writeFileSync(path.join(dir, 'main.ts'), "const headers = { 'x-token': 't' };\nexport const init = { headers };\n");
|
|
69
|
+
const [program] = [...programBatches([path.join(dir, 'main.ts')], { strict: true, noEmit: true })];
|
|
70
|
+
const main = program.getSourceFile(path.join(dir, 'main.ts'));
|
|
71
|
+
let shorthand;
|
|
72
|
+
forEachNode(main, (n) => { if (ts.isShorthandPropertyAssignment(n))
|
|
73
|
+
shorthand = n; });
|
|
74
|
+
const decl = symbolOf(program.getTypeChecker(), shorthand.name)?.valueDeclaration;
|
|
75
|
+
expect(decl && ts.isVariableDeclaration(decl) && decl.name.getText()).toBe('headers');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
export declare const DEFAULT_BATCH_SIZE = 200;
|
|
3
|
+
export interface ProjectConfig {
|
|
4
|
+
options: ts.CompilerOptions;
|
|
5
|
+
/** Every source file the tsconfig includes; empty without a tsconfig. */
|
|
6
|
+
fileNames: string[];
|
|
7
|
+
}
|
|
8
|
+
export declare function loadProjectConfig(cwd: string): ProjectConfig;
|
|
9
|
+
/**
|
|
10
|
+
* Programs over `rootFiles` in batches. Each batch reuses the previous
|
|
11
|
+
* program's unchanged source files, and only one program is alive at a time.
|
|
12
|
+
*/
|
|
13
|
+
export declare function programBatches(rootFiles: string[], options: ts.CompilerOptions, batchSize?: number): Generator<ts.Program>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeScript programs for semantic analysis.
|
|
3
|
+
*
|
|
4
|
+
* Compiler options come from the nearest tsconfig through the compiler's own
|
|
5
|
+
* parser, so `extends` and `paths` behave as they do for tsc. Measured on a
|
|
6
|
+
* 2,400-file Next.js repo: a program for a few scoped files builds in ~0.5s;
|
|
7
|
+
* a full scan, in batches and with external packages left unresolved, runs in
|
|
8
|
+
* ~14s at ~1.3GB RSS (it was ~29s and ~2.8GB with package types loaded).
|
|
9
|
+
*/
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import ts from 'typescript';
|
|
12
|
+
export const DEFAULT_BATCH_SIZE = 200;
|
|
13
|
+
export function loadProjectConfig(cwd) {
|
|
14
|
+
const configPath = ts.findConfigFile(cwd, ts.sys.fileExists, 'tsconfig.json');
|
|
15
|
+
const base = { allowJs: true, checkJs: false };
|
|
16
|
+
if (!configPath || !path.resolve(configPath).startsWith(path.resolve(cwd))) {
|
|
17
|
+
return { options: analysisOptions(base), fileNames: [] };
|
|
18
|
+
}
|
|
19
|
+
const read = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
20
|
+
if (read.error)
|
|
21
|
+
return { options: analysisOptions(base), fileNames: [] };
|
|
22
|
+
const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, path.dirname(configPath));
|
|
23
|
+
return { options: analysisOptions({ ...base, ...parsed.options }), fileNames: parsed.fileNames };
|
|
24
|
+
}
|
|
25
|
+
/** Options for analysis only: nothing is emitted and lib files are not checked. */
|
|
26
|
+
function analysisOptions(options) {
|
|
27
|
+
return { ...options, noEmit: true, skipLibCheck: true, types: [], incremental: false, composite: false };
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A compiler host that resolves project modules but not external packages.
|
|
31
|
+
* The rules need project functions (relative and `paths` imports) and the
|
|
32
|
+
* built-in lib globals (`fetch`, `Promise`, `Response`), not the .d.ts graph
|
|
33
|
+
* behind next, react or a database client. Without this, one batch of a
|
|
34
|
+
* 2,400-file repo pulls in that whole graph (~2.7GB RSS whatever the batch
|
|
35
|
+
* size); with it, memory follows the project's own code.
|
|
36
|
+
*/
|
|
37
|
+
function projectOnlyHost(options) {
|
|
38
|
+
const host = ts.createCompilerHost(options);
|
|
39
|
+
host.resolveModuleNameLiterals = (literals, containingFile, _redirect, opts) => literals.map(literal => {
|
|
40
|
+
const resolved = ts.resolveModuleName(literal.text, containingFile, opts, host).resolvedModule;
|
|
41
|
+
return { resolvedModule: resolved && !resolved.isExternalLibraryImport ? resolved : undefined };
|
|
42
|
+
});
|
|
43
|
+
return host;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Programs over `rootFiles` in batches. Each batch reuses the previous
|
|
47
|
+
* program's unchanged source files, and only one program is alive at a time.
|
|
48
|
+
*/
|
|
49
|
+
export function* programBatches(rootFiles, options, batchSize = DEFAULT_BATCH_SIZE) {
|
|
50
|
+
const host = projectOnlyHost(options);
|
|
51
|
+
let previous;
|
|
52
|
+
for (let i = 0; i < rootFiles.length; i += batchSize) {
|
|
53
|
+
const program = ts.createProgram({
|
|
54
|
+
rootNames: rootFiles.slice(i, i + batchSize),
|
|
55
|
+
options,
|
|
56
|
+
host,
|
|
57
|
+
oldProgram: previous,
|
|
58
|
+
});
|
|
59
|
+
previous = program;
|
|
60
|
+
yield program;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* credential-redirect: a custom credential header is sent with a fetch that
|
|
3
|
+
* may follow redirects.
|
|
4
|
+
*
|
|
5
|
+
* On a cross-origin redirect fetch drops `Authorization` (WHATWG fetch, as
|
|
6
|
+
* implemented by undici) but keeps every custom header, so a token sent as
|
|
7
|
+
* `x-webhook-token` goes to whatever host the target redirects to. The rule
|
|
8
|
+
* follows the header from the call that sets it, through any wrapper that
|
|
9
|
+
* forwards its `init` argument, to the fetch call. A fetch whose init sets
|
|
10
|
+
* `redirect` is treated as handled.
|
|
11
|
+
*/
|
|
12
|
+
import ts from 'typescript';
|
|
13
|
+
import { calledFunction, calleeName, forEachNode, lineOf, locationOf, propertyNameText, relativeFile, snippet, unwrap } from '../ast.js';
|
|
14
|
+
import { originsOf, propertyValues } from '../origins.js';
|
|
15
|
+
const CREDENTIAL_HEADER = /^(?:x-[\w-]*(?:token|secret|key|signature|password|auth[\w-]*)|[\w-]*(?:api[-_]?key|secret|access[-_]?token|private[-_]?token))$/i;
|
|
16
|
+
/** Removed by fetch (or not settable) on a cross-origin redirect. */
|
|
17
|
+
const STRIPPED_ON_REDIRECT = /^(?:authorization|proxy-authorization|cookie)$/i;
|
|
18
|
+
const SECRET_ENV = /SECRET|TOKEN|API_?KEY|PASSWORD|PRIVATE_KEY/i;
|
|
19
|
+
export const credentialRedirect = {
|
|
20
|
+
id: 'credential-redirect',
|
|
21
|
+
check(ctx) {
|
|
22
|
+
const findings = [];
|
|
23
|
+
forEachNode(ctx.sourceFile, (node) => {
|
|
24
|
+
if (!ts.isCallExpression(node))
|
|
25
|
+
return;
|
|
26
|
+
const finding = isFetchCall(node) ? checkDirect(ctx, node) : checkWrapperCall(ctx, node);
|
|
27
|
+
if (finding)
|
|
28
|
+
findings.push(finding);
|
|
29
|
+
});
|
|
30
|
+
return findings;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
function isFetchCall(call) {
|
|
34
|
+
return calleeName(call) === 'fetch' && call.arguments.length >= 2;
|
|
35
|
+
}
|
|
36
|
+
function checkDirect(ctx, call) {
|
|
37
|
+
const init = call.arguments[1];
|
|
38
|
+
if (setsRedirect(ctx.checker, init))
|
|
39
|
+
return null;
|
|
40
|
+
const credential = credentialIn(ctx.checker, init);
|
|
41
|
+
return credential ? finding(ctx, call, credential, call, call.arguments[0]) : null;
|
|
42
|
+
}
|
|
43
|
+
function checkWrapperCall(ctx, call) {
|
|
44
|
+
const fn = calledFunction(ctx.checker, call);
|
|
45
|
+
if (!fn)
|
|
46
|
+
return null;
|
|
47
|
+
for (const [index, sink] of initParams(ctx.summaries, fn)) {
|
|
48
|
+
const arg = call.arguments[index];
|
|
49
|
+
if (!arg || setsRedirect(ctx.checker, arg))
|
|
50
|
+
continue;
|
|
51
|
+
const credential = credentialIn(ctx.checker, arg);
|
|
52
|
+
const url = sink.urlParam === undefined ? undefined : call.arguments[sink.urlParam];
|
|
53
|
+
if (credential)
|
|
54
|
+
return finding(ctx, call, credential, sink.fetchCall, url);
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
/** Parameters of `fn` that reach a fetch init with no redirect guard. */
|
|
59
|
+
function initParams(summaries, fn) {
|
|
60
|
+
return summaries.memo('credential-redirect:init-params', fn, new Map(), () => {
|
|
61
|
+
const params = new Map();
|
|
62
|
+
forEachNode(fn, (node) => {
|
|
63
|
+
if (!ts.isCallExpression(node))
|
|
64
|
+
return;
|
|
65
|
+
if (isFetchCall(node)) {
|
|
66
|
+
const urlParam = directParam(summaries.checker, node.arguments[0], fn);
|
|
67
|
+
for (const index of paramsFlowingInto(summaries.checker, node.arguments[1], fn))
|
|
68
|
+
params.set(index, { fetchCall: node, urlParam });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const inner = calledFunction(summaries.checker, node);
|
|
72
|
+
if (!inner || inner === fn)
|
|
73
|
+
return;
|
|
74
|
+
for (const [innerIndex, sink] of initParams(summaries, inner)) {
|
|
75
|
+
const arg = node.arguments[innerIndex];
|
|
76
|
+
if (!arg)
|
|
77
|
+
continue;
|
|
78
|
+
const innerUrl = sink.urlParam === undefined ? undefined : node.arguments[sink.urlParam];
|
|
79
|
+
const urlParam = innerUrl ? directParam(summaries.checker, innerUrl, fn) : undefined;
|
|
80
|
+
for (const index of paramsFlowingInto(summaries.checker, arg, fn))
|
|
81
|
+
params.set(index, { fetchCall: sink.fetchCall, urlParam });
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
return params;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/** The index of `fn`'s parameter that `expr` is, if it is one. */
|
|
88
|
+
function directParam(checker, expr, fn) {
|
|
89
|
+
if (!expr)
|
|
90
|
+
return undefined;
|
|
91
|
+
const origin = originsOf(checker, expr).find(o => o.kind === 'param' && o.fn === fn);
|
|
92
|
+
return origin?.kind === 'param' ? origin.index : undefined;
|
|
93
|
+
}
|
|
94
|
+
/** `https://host/...` written as a literal (or a template whose host is literal): a fixed endpoint. */
|
|
95
|
+
function fixedHttpsHost(checker, url) {
|
|
96
|
+
if (!url)
|
|
97
|
+
return undefined;
|
|
98
|
+
const origins = originsOf(checker, url);
|
|
99
|
+
if (origins.length !== 1 || origins[0].kind !== 'literal')
|
|
100
|
+
return undefined;
|
|
101
|
+
const node = origins[0].node;
|
|
102
|
+
const text = ts.isStringLiteralLike(node) ? node.text : ts.isTemplateExpression(node) ? templatePrefix(checker, node) : '';
|
|
103
|
+
return text.match(/^https:\/\/([^/${}\s]+)(?:\/|$)/)?.[1];
|
|
104
|
+
}
|
|
105
|
+
/** Literal start of a template, resolving a leading constant: `${BASE_URL}/task` → "https://host/task". */
|
|
106
|
+
function templatePrefix(checker, node) {
|
|
107
|
+
if (node.head.text || node.templateSpans.length === 0)
|
|
108
|
+
return node.head.text;
|
|
109
|
+
const [first] = node.templateSpans;
|
|
110
|
+
const origins = originsOf(checker, first.expression);
|
|
111
|
+
const base = origins.length === 1 && origins[0].kind === 'literal' && ts.isStringLiteralLike(origins[0].node) ? origins[0].node.text : '';
|
|
112
|
+
return base ? base + first.literal.text : '';
|
|
113
|
+
}
|
|
114
|
+
/** Indices of `fn`'s parameters that `expr` passes through (directly or spread) without adding `redirect`. */
|
|
115
|
+
function paramsFlowingInto(checker, expr, fn) {
|
|
116
|
+
const indices = [];
|
|
117
|
+
for (const origin of originsOf(checker, expr)) {
|
|
118
|
+
if (origin.kind === 'param' && origin.fn === fn)
|
|
119
|
+
indices.push(origin.index);
|
|
120
|
+
if (origin.kind !== 'object' || propertyValues(checker, origin.node, 'redirect').values.length > 0)
|
|
121
|
+
continue;
|
|
122
|
+
for (const spread of propertyValues(checker, origin.node, 'redirect').spreads) {
|
|
123
|
+
for (const inner of originsOf(checker, spread)) {
|
|
124
|
+
if (inner.kind === 'param' && inner.fn === fn)
|
|
125
|
+
indices.push(inner.index);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return indices;
|
|
130
|
+
}
|
|
131
|
+
/** The init (or an object spread into it) sets `redirect` explicitly. */
|
|
132
|
+
function setsRedirect(checker, init) {
|
|
133
|
+
return originsOf(checker, init).some(origin => origin.kind === 'object' && propertyValues(checker, origin.node, 'redirect').values.length > 0);
|
|
134
|
+
}
|
|
135
|
+
/** First custom credential header in an init's `headers`. */
|
|
136
|
+
function credentialIn(checker, init) {
|
|
137
|
+
for (const origin of originsOf(checker, init)) {
|
|
138
|
+
if (origin.kind !== 'object')
|
|
139
|
+
continue;
|
|
140
|
+
for (const headers of propertyValues(checker, origin.node, 'headers').values) {
|
|
141
|
+
const found = credentialHeader(checker, headers);
|
|
142
|
+
if (found)
|
|
143
|
+
return found;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
function credentialHeader(checker, headers) {
|
|
149
|
+
for (const origin of originsOf(checker, headersObject(headers))) {
|
|
150
|
+
if (origin.kind !== 'object')
|
|
151
|
+
continue;
|
|
152
|
+
for (const prop of origin.node.properties) {
|
|
153
|
+
if (!ts.isPropertyAssignment(prop))
|
|
154
|
+
continue;
|
|
155
|
+
const header = propertyNameText(prop.name);
|
|
156
|
+
if (!header || STRIPPED_ON_REDIRECT.test(header))
|
|
157
|
+
continue;
|
|
158
|
+
if (CREDENTIAL_HEADER.test(header) || readsSecretEnv(prop.initializer))
|
|
159
|
+
return { header, node: prop };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
/** `new Headers({...})` → the object literal inside. */
|
|
165
|
+
function headersObject(expr) {
|
|
166
|
+
const node = unwrap(expr);
|
|
167
|
+
if (ts.isNewExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'Headers' && node.arguments?.[0]) {
|
|
168
|
+
return node.arguments[0];
|
|
169
|
+
}
|
|
170
|
+
return node;
|
|
171
|
+
}
|
|
172
|
+
/** `process.env.WEBHOOK_SECRET` (possibly inside a template or `??`). */
|
|
173
|
+
function readsSecretEnv(value) {
|
|
174
|
+
let found = false;
|
|
175
|
+
const walk = (node) => {
|
|
176
|
+
if (found)
|
|
177
|
+
return;
|
|
178
|
+
if (ts.isPropertyAccessExpression(node) && ts.isPropertyAccessExpression(node.expression)
|
|
179
|
+
&& node.expression.name.text === 'env' && ts.isIdentifier(node.expression.expression)
|
|
180
|
+
&& node.expression.expression.text === 'process' && SECRET_ENV.test(node.name.text)) {
|
|
181
|
+
found = true;
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
ts.forEachChild(node, walk);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
walk(value);
|
|
188
|
+
return found;
|
|
189
|
+
}
|
|
190
|
+
function finding(ctx, call, credential, fetchCall, url) {
|
|
191
|
+
const fixedHost = fixedHttpsHost(ctx.checker, url);
|
|
192
|
+
const via = fetchCall === call
|
|
193
|
+
? `\`${snippet(call, 50)}\``
|
|
194
|
+
: `\`${calleeName(call) ?? 'the call'}(...)\`, which forwards it to \`${snippet(fetchCall, 40)}\` (${locationOf(ctx.cwd, fetchCall)})`;
|
|
195
|
+
return {
|
|
196
|
+
rule: 'credential-redirect',
|
|
197
|
+
// A fixed https vendor endpoint only leaks if that vendor redirects across hosts;
|
|
198
|
+
// a configurable or computed target is the risk this rule exists for.
|
|
199
|
+
severity: fixedHost ? 'low' : 'high',
|
|
200
|
+
provenance: 'security',
|
|
201
|
+
file: relativeFile(ctx.cwd, ctx.sourceFile),
|
|
202
|
+
line: lineOf(call),
|
|
203
|
+
message: `The \`${credential.header}\` header is sent through ${via} without \`redirect: "manual"\`. `
|
|
204
|
+
+ `fetch drops Authorization on a cross-origin redirect but keeps custom headers, so this credential goes to any host the target redirects to.`
|
|
205
|
+
+ (fixedHost ? ` The target is the fixed endpoint ${fixedHost}, so this only matters if that host redirects.` : ''),
|
|
206
|
+
hint: 'Set `redirect: "manual"` (or "error") on the request and treat a 3xx as a failure, or send the credential in the Authorization header.',
|
|
207
|
+
evidence: [locationOf(ctx.cwd, credential.node), `${locationOf(ctx.cwd, fetchCall)} ${snippet(fetchCall, 50)}`],
|
|
208
|
+
};
|
|
209
|
+
}
|