@apiposture/cli 1.0.1 → 1.1.3
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/.github/workflows/test.yml +1 -1
- package/dist/core/discovery/express-discoverer.js +41 -12
- package/dist/core/discovery/route-group-registry.d.ts +3 -0
- package/dist/core/discovery/route-group-registry.js +15 -0
- package/dist/lib.d.ts +15 -0
- package/dist/lib.js +17 -0
- package/dist/rules/consistency/missing-auth-on-writes.d.ts +1 -0
- package/dist/rules/consistency/missing-auth-on-writes.js +31 -1
- package/package.json +13 -5
|
@@ -35,7 +35,7 @@ export class ExpressDiscoverer {
|
|
|
35
35
|
collectRouterMounts(sourceFile, filePath) {
|
|
36
36
|
const callExpressions = findNodes(sourceFile, ts.isCallExpression);
|
|
37
37
|
for (const callExpr of callExpressions) {
|
|
38
|
-
// Look for app.use('/prefix', router)
|
|
38
|
+
// Look for app.use('/prefix', router) or app.use('/path', middleware())
|
|
39
39
|
if (!ts.isPropertyAccessExpression(callExpr.expression))
|
|
40
40
|
continue;
|
|
41
41
|
const propAccess = callExpr.expression;
|
|
@@ -47,17 +47,31 @@ export class ExpressDiscoverer {
|
|
|
47
47
|
continue;
|
|
48
48
|
const firstArg = args[0];
|
|
49
49
|
const secondArg = args[1];
|
|
50
|
-
//
|
|
51
|
-
if (ts.isStringLiteral(firstArg)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
50
|
+
// Must start with a string path
|
|
51
|
+
if (!ts.isStringLiteral(firstArg))
|
|
52
|
+
continue;
|
|
53
|
+
const prefix = firstArg.text;
|
|
54
|
+
const appName = ts.isIdentifier(propAccess.expression)
|
|
55
|
+
? propAccess.expression.text
|
|
56
|
+
: '';
|
|
57
|
+
if (!appName)
|
|
58
|
+
continue;
|
|
59
|
+
// Check for app.use('/prefix', router) — router is a simple identifier
|
|
60
|
+
if (ts.isIdentifier(secondArg)) {
|
|
61
|
+
this.registry.registerRouterMount(filePath, appName, prefix, secondArg.text);
|
|
62
|
+
}
|
|
63
|
+
// Also collect path-scoped middleware: app.use('/path', middleware(), ...)
|
|
64
|
+
// These middleware apply to all routes matching the path prefix
|
|
65
|
+
const pathMiddlewares = [];
|
|
66
|
+
for (let i = 1; i < args.length; i++) {
|
|
67
|
+
const mwName = this.extractMiddlewareName(args[i], sourceFile);
|
|
68
|
+
if (mwName) {
|
|
69
|
+
pathMiddlewares.push(mwName);
|
|
59
70
|
}
|
|
60
71
|
}
|
|
72
|
+
if (pathMiddlewares.length > 0) {
|
|
73
|
+
this.registry.registerPathMiddleware(filePath, prefix, pathMiddlewares);
|
|
74
|
+
}
|
|
61
75
|
}
|
|
62
76
|
}
|
|
63
77
|
processCallExpression(callExpr, sourceFile, filePath) {
|
|
@@ -90,14 +104,29 @@ export class ExpressDiscoverer {
|
|
|
90
104
|
const fullRoute = this.normalizePath(prefix + routePath);
|
|
91
105
|
// Extract middleware chain (all arguments except last handler)
|
|
92
106
|
const middlewares = this.extractMiddlewares(args, sourceFile);
|
|
107
|
+
// When there are exactly 2 args (path + one function), the function is treated
|
|
108
|
+
// as the handler. But it might actually be auth middleware (e.g., security.isAuthorized()).
|
|
109
|
+
// Also extract its name as potential middleware so auth extraction can check it.
|
|
110
|
+
const lastArg = args[args.length - 1];
|
|
111
|
+
if (args.length === 2 && ts.isStringLiteral(args[0])) {
|
|
112
|
+
const lastArgName = this.extractMiddlewareName(lastArg, sourceFile);
|
|
113
|
+
if (lastArgName) {
|
|
114
|
+
middlewares.push(lastArgName);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
93
117
|
// Get handler name
|
|
94
|
-
const handlerName = this.extractHandlerName(
|
|
118
|
+
const handlerName = this.extractHandlerName(lastArg, sourceFile);
|
|
95
119
|
// Get location
|
|
96
120
|
const location = getLineAndColumn(sourceFile, callExpr.getStart(sourceFile));
|
|
121
|
+
// Include path-scoped middleware registered via app.use('/path', middleware())
|
|
122
|
+
const pathMiddlewares = this.registry.getPathMiddlewares(fullRoute);
|
|
97
123
|
// Extract authorization info
|
|
98
124
|
const authorization = this.authExtractor.extract(middlewares, {
|
|
99
125
|
isRouter: callerName !== 'app',
|
|
100
|
-
routerMiddlewares:
|
|
126
|
+
routerMiddlewares: [
|
|
127
|
+
...this.registry.getAllMiddlewares(filePath, callerName),
|
|
128
|
+
...pathMiddlewares,
|
|
129
|
+
],
|
|
101
130
|
});
|
|
102
131
|
return createEndpoint({
|
|
103
132
|
route: fullRoute,
|
|
@@ -7,11 +7,14 @@ export interface RouteGroup {
|
|
|
7
7
|
export declare class RouteGroupRegistry {
|
|
8
8
|
private groups;
|
|
9
9
|
private routerMounts;
|
|
10
|
+
private pathMiddlewares;
|
|
10
11
|
registerGroup(filePath: string, variableName: string, prefix?: string, middlewares?: string[]): void;
|
|
11
12
|
registerRouterMount(filePath: string, appVariableName: string, prefix: string, routerName: string): void;
|
|
12
13
|
getGroup(filePath: string, variableName: string): RouteGroup | undefined;
|
|
13
14
|
getRouterPrefix(filePath: string, routerName: string): string;
|
|
14
15
|
getAllMiddlewares(filePath: string, variableName: string): string[];
|
|
16
|
+
registerPathMiddleware(filePath: string, prefix: string, middlewares: string[]): void;
|
|
17
|
+
getPathMiddlewares(route: string): string[];
|
|
15
18
|
clear(): void;
|
|
16
19
|
private makeKey;
|
|
17
20
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export class RouteGroupRegistry {
|
|
2
2
|
groups = new Map();
|
|
3
3
|
routerMounts = new Map();
|
|
4
|
+
pathMiddlewares = [];
|
|
4
5
|
registerGroup(filePath, variableName, prefix = '', middlewares = []) {
|
|
5
6
|
const key = this.makeKey(filePath, variableName);
|
|
6
7
|
const group = {
|
|
@@ -39,9 +40,23 @@ export class RouteGroupRegistry {
|
|
|
39
40
|
const group = this.getGroup(filePath, variableName);
|
|
40
41
|
return group?.middlewares ?? [];
|
|
41
42
|
}
|
|
43
|
+
registerPathMiddleware(filePath, prefix, middlewares) {
|
|
44
|
+
this.pathMiddlewares.push({ prefix, middlewares, filePath });
|
|
45
|
+
}
|
|
46
|
+
getPathMiddlewares(route) {
|
|
47
|
+
const result = [];
|
|
48
|
+
for (const pm of this.pathMiddlewares) {
|
|
49
|
+
// Check if route starts with the middleware prefix
|
|
50
|
+
if (route === pm.prefix || route.startsWith(pm.prefix + '/')) {
|
|
51
|
+
result.push(...pm.middlewares);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
42
56
|
clear() {
|
|
43
57
|
this.groups.clear();
|
|
44
58
|
this.routerMounts.clear();
|
|
59
|
+
this.pathMiddlewares = [];
|
|
45
60
|
}
|
|
46
61
|
makeKey(filePath, variableName) {
|
|
47
62
|
return `${filePath}::${variableName}`;
|
package/dist/lib.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Library entry point for @apiposture/cli.
|
|
3
|
+
* Re-exports all public types, functions, and classes for use by other packages.
|
|
4
|
+
*/
|
|
5
|
+
export * from './core/models/index.js';
|
|
6
|
+
export { ProjectAnalyzer } from './core/analysis/project-analyzer.js';
|
|
7
|
+
export { SourceFileLoader } from './core/analysis/source-file-loader.js';
|
|
8
|
+
export * from './core/discovery/index.js';
|
|
9
|
+
export { RuleEngine } from './rules/rule-engine.js';
|
|
10
|
+
export type { SecurityRule } from './rules/rule-interface.js';
|
|
11
|
+
export type { OutputFormatter, FormatterOptions } from './output/formatter-interface.js';
|
|
12
|
+
export { TerminalFormatter } from './output/terminal-formatter.js';
|
|
13
|
+
export { JsonFormatter } from './output/json-formatter.js';
|
|
14
|
+
export { MarkdownFormatter } from './output/markdown-formatter.js';
|
|
15
|
+
//# sourceMappingURL=lib.d.ts.map
|
package/dist/lib.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Library entry point for @apiposture/cli.
|
|
3
|
+
* Re-exports all public types, functions, and classes for use by other packages.
|
|
4
|
+
*/
|
|
5
|
+
// Models
|
|
6
|
+
export * from './core/models/index.js';
|
|
7
|
+
// Analysis
|
|
8
|
+
export { ProjectAnalyzer } from './core/analysis/project-analyzer.js';
|
|
9
|
+
export { SourceFileLoader } from './core/analysis/source-file-loader.js';
|
|
10
|
+
// Discovery
|
|
11
|
+
export * from './core/discovery/index.js';
|
|
12
|
+
// Rules
|
|
13
|
+
export { RuleEngine } from './rules/rule-engine.js';
|
|
14
|
+
export { TerminalFormatter } from './output/terminal-formatter.js';
|
|
15
|
+
export { JsonFormatter } from './output/json-formatter.js';
|
|
16
|
+
export { MarkdownFormatter } from './output/markdown-formatter.js';
|
|
17
|
+
//# sourceMappingURL=lib.js.map
|
|
@@ -16,6 +16,7 @@ export declare class MissingAuthOnWrites implements SecurityRule {
|
|
|
16
16
|
readonly description = "Write operation has no authentication and no explicit public marker";
|
|
17
17
|
readonly severity = Severity.Critical;
|
|
18
18
|
evaluate(endpoint: Endpoint): Finding[];
|
|
19
|
+
private isAuthEndpoint;
|
|
19
20
|
private getRecommendation;
|
|
20
21
|
}
|
|
21
22
|
//# sourceMappingURL=missing-auth-on-writes.d.ts.map
|
|
@@ -2,6 +2,31 @@ import { createFinding } from '../../core/models/finding.js';
|
|
|
2
2
|
import { Severity } from '../../core/models/severity.js';
|
|
3
3
|
import { SecurityClassification } from '../../core/models/security-classification.js';
|
|
4
4
|
import { isWriteMethod } from '../../core/models/http-method.js';
|
|
5
|
+
// Routes that are inherently public (authentication endpoints themselves)
|
|
6
|
+
const AUTH_ENDPOINT_PATTERNS = [
|
|
7
|
+
/^\/login$/i,
|
|
8
|
+
/^\/signin$/i,
|
|
9
|
+
/^\/signup$/i,
|
|
10
|
+
/^\/register$/i,
|
|
11
|
+
/^\/auth\/login$/i,
|
|
12
|
+
/^\/auth\/register$/i,
|
|
13
|
+
/^\/auth\/signup$/i,
|
|
14
|
+
/^\/api\/auth\/login$/i,
|
|
15
|
+
/^\/api\/auth\/register$/i,
|
|
16
|
+
/^\/api\/auth\/signup$/i,
|
|
17
|
+
/\/users\/login$/i,
|
|
18
|
+
/\/users\/register$/i,
|
|
19
|
+
/\/users\/signup$/i,
|
|
20
|
+
/\/user\/login$/i,
|
|
21
|
+
/\/user\/register$/i,
|
|
22
|
+
/\/user\/signup$/i,
|
|
23
|
+
/\/user\/reset-password$/i,
|
|
24
|
+
/\/password\/reset$/i,
|
|
25
|
+
/\/password\/forgot$/i,
|
|
26
|
+
/\/forgot-?password$/i,
|
|
27
|
+
/\/reset-?password$/i,
|
|
28
|
+
/\/oauth\/token$/i,
|
|
29
|
+
];
|
|
5
30
|
/**
|
|
6
31
|
* AP004: Missing authentication on write operations
|
|
7
32
|
*
|
|
@@ -22,10 +47,12 @@ export class MissingAuthOnWrites {
|
|
|
22
47
|
// 1. It's a write method
|
|
23
48
|
// 2. No authentication
|
|
24
49
|
// 3. No explicit public marker (so it's accidentally open, not intentionally)
|
|
50
|
+
// 4. Not an authentication endpoint itself (login/signup/register are inherently public)
|
|
25
51
|
if (isWriteMethod(endpoint.method) &&
|
|
26
52
|
auth.classification === SecurityClassification.Public &&
|
|
27
53
|
!auth.isAuthenticated &&
|
|
28
|
-
!auth.isExplicitlyPublic
|
|
54
|
+
!auth.isExplicitlyPublic &&
|
|
55
|
+
!this.isAuthEndpoint(endpoint.route)) {
|
|
29
56
|
findings.push(createFinding({
|
|
30
57
|
ruleId: this.id,
|
|
31
58
|
ruleName: this.name,
|
|
@@ -38,6 +65,9 @@ export class MissingAuthOnWrites {
|
|
|
38
65
|
}
|
|
39
66
|
return findings;
|
|
40
67
|
}
|
|
68
|
+
isAuthEndpoint(route) {
|
|
69
|
+
return AUTH_ENDPOINT_PATTERNS.some((pattern) => pattern.test(route));
|
|
70
|
+
}
|
|
41
71
|
getRecommendation(endpoint) {
|
|
42
72
|
switch (endpoint.type) {
|
|
43
73
|
case 'express':
|
package/package.json
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apiposture/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.3",
|
|
4
4
|
"description": "Static source-code analysis CLI for Node.js API frameworks to identify authorization misconfigurations and security risks",
|
|
5
|
-
"main": "dist/
|
|
5
|
+
"main": "dist/lib.js",
|
|
6
|
+
"types": "dist/lib.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/lib.d.ts",
|
|
10
|
+
"default": "./dist/lib.js"
|
|
11
|
+
},
|
|
12
|
+
"./dist/*": "./dist/*"
|
|
13
|
+
},
|
|
6
14
|
"bin": {
|
|
7
15
|
"apiposture": "dist/index.js"
|
|
8
16
|
},
|
|
@@ -38,10 +46,10 @@
|
|
|
38
46
|
},
|
|
39
47
|
"homepage": "https://github.com/BlagoCuljak/ApiPosture.Node.js#readme",
|
|
40
48
|
"engines": {
|
|
41
|
-
"node": ">=
|
|
49
|
+
"node": ">=20.0.0"
|
|
42
50
|
},
|
|
43
51
|
"dependencies": {
|
|
44
|
-
"axios": "^1.
|
|
52
|
+
"axios": "^1.13.5",
|
|
45
53
|
"chalk": "^5.3.0",
|
|
46
54
|
"cli-table3": "^0.6.3",
|
|
47
55
|
"commander": "^12.0.0",
|
|
@@ -55,6 +63,6 @@
|
|
|
55
63
|
"eslint": "^8.56.0",
|
|
56
64
|
"tsx": "^4.7.0",
|
|
57
65
|
"typescript": "^5.3.0",
|
|
58
|
-
"vitest": "^
|
|
66
|
+
"vitest": "^4.0.18"
|
|
59
67
|
}
|
|
60
68
|
}
|