backend-guard 1.0.0__py3-none-any.whl
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.
- auditor/__init__.py +10 -0
- auditor/analyzers/__init__.py +27 -0
- auditor/analyzers/frameworks/express/__init__.py +145 -0
- auditor/analyzers/frameworks/fastapi/__init__.py +160 -0
- auditor/analyzers/frameworks/nestjs/__init__.py +127 -0
- auditor/analyzers/languages/javascript/imports.py +45 -0
- auditor/analyzers/languages/javascript/parser.py +113 -0
- auditor/analyzers/languages/javascript/rules.py +161 -0
- auditor/analyzers/languages/python/async_checks.py +82 -0
- auditor/analyzers/languages/python/imports.py +55 -0
- auditor/analyzers/languages/python/parser.py +45 -0
- auditor/analyzers/languages/python/rules.py +197 -0
- auditor/analyzers/languages/python/typing.py +78 -0
- auditor/analyzers/languages/typescript/imports.py +7 -0
- auditor/analyzers/languages/typescript/parser.py +8 -0
- auditor/analyzers/languages/typescript/rules.py +75 -0
- auditor/analyzers/universal/api_design.py +154 -0
- auditor/analyzers/universal/architecture.py +106 -0
- auditor/analyzers/universal/complexity.py +190 -0
- auditor/analyzers/universal/configuration.py +95 -0
- auditor/analyzers/universal/database_design.py +179 -0
- auditor/analyzers/universal/dependency.py +94 -0
- auditor/analyzers/universal/duplication.py +104 -0
- auditor/analyzers/universal/error_handling.py +194 -0
- auditor/analyzers/universal/logging.py +156 -0
- auditor/analyzers/universal/production_readiness.py +93 -0
- auditor/analyzers/universal/security.py +405 -0
- auditor/analyzers/universal/solid.py +124 -0
- auditor/analyzers/universal/testing.py +136 -0
- auditor/baselines.py +55 -0
- auditor/cli.py +342 -0
- auditor/config.py +186 -0
- auditor/core/__init__.py +19 -0
- auditor/core/analyzer.py +40 -0
- auditor/core/context.py +89 -0
- auditor/core/dependency_graph.py +188 -0
- auditor/core/findings.py +76 -0
- auditor/core/rule.py +134 -0
- auditor/core/severity.py +111 -0
- auditor/discovery.py +433 -0
- auditor/engine.py +146 -0
- auditor/integrations/bandit.py +58 -0
- auditor/integrations/eslint.py +56 -0
- auditor/integrations/external_tools.py +43 -0
- auditor/integrations/mypy.py +54 -0
- auditor/integrations/pytest.py +43 -0
- auditor/integrations/ruff.py +62 -0
- auditor/integrations_external/github_actions.py +22 -0
- auditor/integrations_external/mcp_server.py +121 -0
- auditor/models.py +222 -0
- auditor/project.py +37 -0
- auditor/registry.py +101 -0
- auditor/reporting/json_report.py +15 -0
- auditor/reporting/markdown_report.py +123 -0
- auditor/reporting/sarif_report.py +87 -0
- auditor/reporting/terminal.py +133 -0
- auditor/suppressions.py +112 -0
- backend_guard-1.0.0.dist-info/METADATA +533 -0
- backend_guard-1.0.0.dist-info/RECORD +63 -0
- backend_guard-1.0.0.dist-info/WHEEL +5 -0
- backend_guard-1.0.0.dist-info/entry_points.txt +3 -0
- backend_guard-1.0.0.dist-info/licenses/LICENSE +21 -0
- backend_guard-1.0.0.dist-info/top_level.txt +1 -0
auditor/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Universal Backend Codebase Auditor (Backend Guard).
|
|
2
|
+
|
|
3
|
+
A production-grade, extensible static analysis and architecture auditing tool
|
|
4
|
+
for modern backend systems.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "1.0.0"
|
|
8
|
+
__tool_name__ = "backend-guard"
|
|
9
|
+
__project_name__ = "Universal Backend Auditor"
|
|
10
|
+
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# ruff: noqa: F401, I001
|
|
2
|
+
"""Analyzer initialization and dynamic discovery loader."""
|
|
3
|
+
|
|
4
|
+
import auditor.analyzers.universal.architecture
|
|
5
|
+
import auditor.analyzers.universal.solid
|
|
6
|
+
import auditor.analyzers.universal.dependency
|
|
7
|
+
import auditor.analyzers.universal.complexity
|
|
8
|
+
import auditor.analyzers.universal.duplication
|
|
9
|
+
import auditor.analyzers.universal.security
|
|
10
|
+
import auditor.analyzers.universal.error_handling
|
|
11
|
+
import auditor.analyzers.universal.logging
|
|
12
|
+
import auditor.analyzers.universal.configuration
|
|
13
|
+
import auditor.analyzers.universal.database_design
|
|
14
|
+
import auditor.analyzers.universal.api_design
|
|
15
|
+
import auditor.analyzers.universal.testing
|
|
16
|
+
import auditor.analyzers.universal.production_readiness
|
|
17
|
+
import auditor.analyzers.languages.python.rules
|
|
18
|
+
import auditor.analyzers.languages.javascript.rules
|
|
19
|
+
import auditor.analyzers.languages.typescript.rules
|
|
20
|
+
import auditor.analyzers.frameworks.fastapi
|
|
21
|
+
import auditor.analyzers.frameworks.express
|
|
22
|
+
import auditor.analyzers.frameworks.nestjs
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def ensure_analyzers_loaded() -> None:
|
|
26
|
+
"""Ensure all analyzer and rule modules are imported and registered."""
|
|
27
|
+
pass
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Express framework analyzer and rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import List
|
|
7
|
+
|
|
8
|
+
from auditor.analyzers.languages.javascript.parser import parse_js_file
|
|
9
|
+
from auditor.core.analyzer import BaseAnalyzer
|
|
10
|
+
from auditor.core.context import AnalysisContext
|
|
11
|
+
from auditor.core.rule import BaseRule, RuleMetadata
|
|
12
|
+
from auditor.core.severity import Category, Confidence, Severity
|
|
13
|
+
from auditor.models import Finding
|
|
14
|
+
from auditor.registry import register_analyzer, register_rule
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@register_rule
|
|
18
|
+
class ExpressMissingErrorMiddlewareRule(BaseRule):
|
|
19
|
+
"""Detects Express applications missing standard 4-argument error handling middleware."""
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
super().__init__(
|
|
23
|
+
RuleMetadata(
|
|
24
|
+
rule_id="EXPRESS-001",
|
|
25
|
+
title="Missing global error handling middleware in Express",
|
|
26
|
+
category=Category.ERROR_HANDLING,
|
|
27
|
+
severity=Severity.HIGH,
|
|
28
|
+
confidence=Confidence.HIGH,
|
|
29
|
+
description="Express server registers routes but contains no 4-argument error-handling middleware (err, req, res, next).",
|
|
30
|
+
impact="Uncaught errors fall back to default HTML error pages, leaking internal stack traces or hanging client requests.",
|
|
31
|
+
recommendation="Register a centralized 4-parameter error handler at the end of the middleware chain.",
|
|
32
|
+
suggested_fix="app.use((err, req, res, next) => { logger.error(err); res.status(500).json({ error: 'Internal Server Error' }); });",
|
|
33
|
+
framework="express",
|
|
34
|
+
language="javascript",
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def evaluate(self, context: AnalysisContext) -> List[Finding]:
|
|
39
|
+
findings: List[Finding] = []
|
|
40
|
+
js_files = context.get_files_for_language("javascript") + context.get_files_for_language(
|
|
41
|
+
"typescript"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
has_routes = False
|
|
45
|
+
has_error_middleware = False
|
|
46
|
+
server_entry_file = None
|
|
47
|
+
|
|
48
|
+
for f in js_files:
|
|
49
|
+
data = context.get_js_structure(f) or parse_js_file(f)
|
|
50
|
+
context.set_js_structure(f, data)
|
|
51
|
+
|
|
52
|
+
if data.get("expressRoutes"):
|
|
53
|
+
has_routes = True
|
|
54
|
+
if data.get("expressErrorMiddleware"):
|
|
55
|
+
has_error_middleware = True
|
|
56
|
+
|
|
57
|
+
rel = context.get_relative_path(f).lower()
|
|
58
|
+
if any(
|
|
59
|
+
term in rel
|
|
60
|
+
for term in ["app.js", "server.js", "index.js", "app.ts", "server.ts", "index.ts"]
|
|
61
|
+
):
|
|
62
|
+
server_entry_file = f
|
|
63
|
+
|
|
64
|
+
if has_routes and not has_error_middleware:
|
|
65
|
+
target_path = server_entry_file or (js_files[0] if js_files else context.root_path)
|
|
66
|
+
findings.append(
|
|
67
|
+
self.create_finding(
|
|
68
|
+
file=context.get_relative_path(target_path),
|
|
69
|
+
line=1,
|
|
70
|
+
description="Express application defines routes but lacks a centralized (err, req, res, next) error handler.",
|
|
71
|
+
evidence="No 4-parameter middleware registered across application files",
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
return findings
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@register_rule
|
|
78
|
+
class ExpressAsyncRouteWithoutTryCatchRule(BaseRule):
|
|
79
|
+
"""Detects async Express route handlers without try/catch or async handler wrapper."""
|
|
80
|
+
|
|
81
|
+
def __init__(self) -> None:
|
|
82
|
+
super().__init__(
|
|
83
|
+
RuleMetadata(
|
|
84
|
+
rule_id="EXPRESS-003",
|
|
85
|
+
title="Async Express route handler without error handling",
|
|
86
|
+
category=Category.ERROR_HANDLING,
|
|
87
|
+
severity=Severity.HIGH,
|
|
88
|
+
confidence=Confidence.HIGH,
|
|
89
|
+
description="Express 4 async route handlers do not catch promise rejections automatically; unhandled errors hang connections indefinitely.",
|
|
90
|
+
impact="A thrown exception inside an async route handler leaves the client request hanging until socket timeout, exhausting server sockets.",
|
|
91
|
+
recommendation="Wrap the async handler with express-async-handler or enclose the body in try/catch calling next(err).",
|
|
92
|
+
suggested_fix="Wrap the handler with asyncHandler(async (req, res) => { ... }) or add try/catch.",
|
|
93
|
+
framework="express",
|
|
94
|
+
language="javascript",
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def evaluate(self, context: AnalysisContext) -> List[Finding]:
|
|
99
|
+
findings: List[Finding] = []
|
|
100
|
+
js_files = context.get_files_for_language("javascript") + context.get_files_for_language(
|
|
101
|
+
"typescript"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
for f in js_files:
|
|
105
|
+
lines = context.get_lines(f)
|
|
106
|
+
rel_file = context.get_relative_path(f)
|
|
107
|
+
|
|
108
|
+
for idx, line in enumerate(lines, start=1):
|
|
109
|
+
# Detect async handler registered directly on router or app
|
|
110
|
+
if re.search(
|
|
111
|
+
r"""\b(?:app|router)\.(?:get|post|put|delete|patch)\s*\(\s*['"][^'"]+['"]\s*,\s*async\s*\(""",
|
|
112
|
+
line,
|
|
113
|
+
):
|
|
114
|
+
# Check next 15 lines for try {
|
|
115
|
+
next_lines = "".join(lines[idx : min(idx + 15, len(lines))])
|
|
116
|
+
if "try" not in next_lines and "asyncHandler" not in line:
|
|
117
|
+
findings.append(
|
|
118
|
+
self.create_finding(
|
|
119
|
+
file=rel_file,
|
|
120
|
+
line=idx,
|
|
121
|
+
description="Async route handler lacks try/catch or asyncHandler wrapper; unhandled rejections will hang requests.",
|
|
122
|
+
evidence=line.strip(),
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
return findings
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@register_analyzer
|
|
129
|
+
class ExpressFrameworkAnalyzer(BaseAnalyzer):
|
|
130
|
+
name = "express-framework"
|
|
131
|
+
description = "Framework analysis for Express.js: error middleware, async route safety, and middleware ordering"
|
|
132
|
+
framework = "express"
|
|
133
|
+
|
|
134
|
+
def get_rules(self) -> List[BaseRule]:
|
|
135
|
+
return [
|
|
136
|
+
ExpressMissingErrorMiddlewareRule(),
|
|
137
|
+
ExpressAsyncRouteWithoutTryCatchRule(),
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
def analyze(self, context: AnalysisContext) -> List[Finding]:
|
|
141
|
+
findings: List[Finding] = []
|
|
142
|
+
for rule in self.get_rules():
|
|
143
|
+
if context.config.is_rule_enabled(rule.rule_id):
|
|
144
|
+
findings.extend(rule.evaluate(context))
|
|
145
|
+
return findings
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""FastAPI framework analyzer and rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from typing import List
|
|
7
|
+
|
|
8
|
+
from auditor.core.analyzer import BaseAnalyzer
|
|
9
|
+
from auditor.core.context import AnalysisContext
|
|
10
|
+
from auditor.core.rule import BaseRule, RuleMetadata
|
|
11
|
+
from auditor.core.severity import Category, Confidence, Severity
|
|
12
|
+
from auditor.models import Finding
|
|
13
|
+
from auditor.registry import register_analyzer, register_rule
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@register_rule
|
|
17
|
+
class FastApiRawSqlInRouteRule(BaseRule):
|
|
18
|
+
"""Detects direct raw SQL execution inside FastAPI route endpoints."""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
super().__init__(
|
|
22
|
+
RuleMetadata(
|
|
23
|
+
rule_id="FASTAPI-001",
|
|
24
|
+
title="Direct raw SQL execution inside route endpoint",
|
|
25
|
+
category=Category.ARCHITECTURE,
|
|
26
|
+
severity=Severity.HIGH,
|
|
27
|
+
confidence=Confidence.HIGH,
|
|
28
|
+
description="FastAPI endpoint directly executes raw SQL query strings instead of delegating to a repository or ORM service.",
|
|
29
|
+
impact="Violates separation of concerns, mixes HTTP transport with database schemas, and makes unit testing endpoints difficult.",
|
|
30
|
+
recommendation="Move database queries into a dedicated repository or service class injected via Depends().",
|
|
31
|
+
suggested_fix="Create a UserRepository and inject it into the route handler via Depends(get_user_repo).",
|
|
32
|
+
framework="fastapi",
|
|
33
|
+
language="python",
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def evaluate(self, context: AnalysisContext) -> List[Finding]:
|
|
38
|
+
findings: List[Finding] = []
|
|
39
|
+
for py_file in context.get_files_for_language("python"):
|
|
40
|
+
tree = context.get_python_ast(py_file)
|
|
41
|
+
if not tree:
|
|
42
|
+
continue
|
|
43
|
+
|
|
44
|
+
for node in ast.walk(tree):
|
|
45
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
46
|
+
# Check if decorated as router endpoint: @router.get/post/etc.
|
|
47
|
+
is_endpoint = False
|
|
48
|
+
for dec in node.decorator_list:
|
|
49
|
+
if isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute):
|
|
50
|
+
if dec.func.attr in (
|
|
51
|
+
"get",
|
|
52
|
+
"post",
|
|
53
|
+
"put",
|
|
54
|
+
"delete",
|
|
55
|
+
"patch",
|
|
56
|
+
"api_route",
|
|
57
|
+
):
|
|
58
|
+
is_endpoint = True
|
|
59
|
+
break
|
|
60
|
+
|
|
61
|
+
if is_endpoint:
|
|
62
|
+
for child in ast.walk(node):
|
|
63
|
+
if isinstance(child, ast.Call) and isinstance(
|
|
64
|
+
child.func, ast.Attribute
|
|
65
|
+
):
|
|
66
|
+
if child.func.attr in ("execute", "raw"):
|
|
67
|
+
if child.args:
|
|
68
|
+
first_arg = child.args[0]
|
|
69
|
+
is_raw_sql = False
|
|
70
|
+
if isinstance(
|
|
71
|
+
first_arg, (ast.Constant, ast.JoinedStr, ast.BinOp)
|
|
72
|
+
):
|
|
73
|
+
is_raw_sql = True
|
|
74
|
+
elif isinstance(first_arg, ast.Name):
|
|
75
|
+
# Variable named query or sql, or lookup in function scope
|
|
76
|
+
if any(
|
|
77
|
+
term in first_arg.id.lower()
|
|
78
|
+
for term in ["query", "sql"]
|
|
79
|
+
):
|
|
80
|
+
is_raw_sql = True
|
|
81
|
+
if is_raw_sql:
|
|
82
|
+
rel_file = context.get_relative_path(py_file)
|
|
83
|
+
findings.append(
|
|
84
|
+
self.create_finding(
|
|
85
|
+
file=rel_file,
|
|
86
|
+
line=child.lineno,
|
|
87
|
+
description=f"Raw SQL execution inside route '{node.name}'.",
|
|
88
|
+
evidence=f"db.{child.func.attr}(...) in @router endpoint",
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
return findings
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@register_rule
|
|
95
|
+
class FastApiMissingResponseModelRule(BaseRule):
|
|
96
|
+
"""Detects mutating route endpoints without response_model or status_code."""
|
|
97
|
+
|
|
98
|
+
def __init__(self) -> None:
|
|
99
|
+
super().__init__(
|
|
100
|
+
RuleMetadata(
|
|
101
|
+
rule_id="FASTAPI-002",
|
|
102
|
+
title="Missing response_model on route endpoint",
|
|
103
|
+
category=Category.API_DESIGN,
|
|
104
|
+
severity=Severity.LOW,
|
|
105
|
+
confidence=Confidence.HIGH,
|
|
106
|
+
description="FastAPI route endpoint does not declare a response_model, bypassing automatic schema filtering and OpenAPI documentation.",
|
|
107
|
+
impact="Internal model fields (like hashed passwords or internal IDs) can be accidentally leaked to clients in responses.",
|
|
108
|
+
recommendation="Explicitly specify response_model=UserResponse in the router decorator.",
|
|
109
|
+
suggested_fix="@router.post('/users', response_model=UserResponse, status_code=201)",
|
|
110
|
+
framework="fastapi",
|
|
111
|
+
language="python",
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def evaluate(self, context: AnalysisContext) -> List[Finding]:
|
|
116
|
+
findings: List[Finding] = []
|
|
117
|
+
for py_file in context.get_files_for_language("python"):
|
|
118
|
+
tree = context.get_python_ast(py_file)
|
|
119
|
+
if not tree:
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
for node in ast.walk(tree):
|
|
123
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
124
|
+
for dec in node.decorator_list:
|
|
125
|
+
if isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute):
|
|
126
|
+
if dec.func.attr in ("post", "put"):
|
|
127
|
+
has_response_model = any(
|
|
128
|
+
kw.arg == "response_model" for kw in dec.keywords
|
|
129
|
+
)
|
|
130
|
+
if not has_response_model:
|
|
131
|
+
rel_file = context.get_relative_path(py_file)
|
|
132
|
+
findings.append(
|
|
133
|
+
self.create_finding(
|
|
134
|
+
file=rel_file,
|
|
135
|
+
line=node.lineno,
|
|
136
|
+
description=f"FastAPI mutating endpoint '{node.name}' has no response_model declared.",
|
|
137
|
+
evidence=f"@{dec.func.attr}(...) without response_model",
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
return findings
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@register_analyzer
|
|
144
|
+
class FastApiFrameworkAnalyzer(BaseAnalyzer):
|
|
145
|
+
name = "fastapi-framework"
|
|
146
|
+
description = (
|
|
147
|
+
"Framework analysis for FastAPI: route boundaries, response models, and async hygiene"
|
|
148
|
+
)
|
|
149
|
+
framework = "fastapi"
|
|
150
|
+
language = "python"
|
|
151
|
+
|
|
152
|
+
def get_rules(self) -> List[BaseRule]:
|
|
153
|
+
return [FastApiRawSqlInRouteRule(), FastApiMissingResponseModelRule()]
|
|
154
|
+
|
|
155
|
+
def analyze(self, context: AnalysisContext) -> List[Finding]:
|
|
156
|
+
findings: List[Finding] = []
|
|
157
|
+
for rule in self.get_rules():
|
|
158
|
+
if context.config.is_rule_enabled(rule.rule_id):
|
|
159
|
+
findings.extend(rule.evaluate(context))
|
|
160
|
+
return findings
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""NestJS framework analyzer and rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import List
|
|
7
|
+
|
|
8
|
+
from auditor.core.analyzer import BaseAnalyzer
|
|
9
|
+
from auditor.core.context import AnalysisContext
|
|
10
|
+
from auditor.core.rule import BaseRule, RuleMetadata
|
|
11
|
+
from auditor.core.severity import Category, Confidence, Severity
|
|
12
|
+
from auditor.models import Finding
|
|
13
|
+
from auditor.registry import register_analyzer, register_rule
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@register_rule
|
|
17
|
+
class NestJsMissingInjectableRule(BaseRule):
|
|
18
|
+
"""Detects service classes in NestJS missing the @Injectable() decorator."""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
super().__init__(
|
|
22
|
+
RuleMetadata(
|
|
23
|
+
rule_id="NEST-001",
|
|
24
|
+
title="Service class missing @Injectable() decorator",
|
|
25
|
+
category=Category.PATTERNS,
|
|
26
|
+
severity=Severity.HIGH,
|
|
27
|
+
confidence=Confidence.HIGH,
|
|
28
|
+
description="NestJS service class is not decorated with @Injectable(), preventing the IoC container from managing its lifecycle.",
|
|
29
|
+
impact="NestJS cannot inject dependencies into this class or provide it to controllers, resulting in runtime DI resolution errors.",
|
|
30
|
+
recommendation="Add @Injectable() decorator above the service class declaration.",
|
|
31
|
+
suggested_fix="@Injectable()\nexport class UserService { ... }",
|
|
32
|
+
framework="nestjs",
|
|
33
|
+
language="typescript",
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def evaluate(self, context: AnalysisContext) -> List[Finding]:
|
|
38
|
+
findings: List[Finding] = []
|
|
39
|
+
ts_files = context.get_files_for_language("typescript")
|
|
40
|
+
|
|
41
|
+
for f in ts_files:
|
|
42
|
+
rel_file = context.get_relative_path(f)
|
|
43
|
+
if not rel_file.endswith(".service.ts"):
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
lines = context.get_lines(f)
|
|
47
|
+
has_injectable = any("@Injectable()" in line for line in lines)
|
|
48
|
+
|
|
49
|
+
for idx, line in enumerate(lines, start=1):
|
|
50
|
+
if re.search(r"""export\s+class\s+[a-zA-Z0-9_]+Service\b""", line):
|
|
51
|
+
if not has_injectable:
|
|
52
|
+
findings.append(
|
|
53
|
+
self.create_finding(
|
|
54
|
+
file=rel_file,
|
|
55
|
+
line=idx,
|
|
56
|
+
description=f"NestJS service in '{rel_file}' is missing @Injectable() decorator.",
|
|
57
|
+
evidence=line.strip(),
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
return findings
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@register_rule
|
|
64
|
+
class NestJsDirectDbInControllerRule(BaseRule):
|
|
65
|
+
"""Detects direct database queries executed in NestJS @Controller()."""
|
|
66
|
+
|
|
67
|
+
def __init__(self) -> None:
|
|
68
|
+
super().__init__(
|
|
69
|
+
RuleMetadata(
|
|
70
|
+
rule_id="NEST-002",
|
|
71
|
+
title="Direct database access inside NestJS controller",
|
|
72
|
+
category=Category.ARCHITECTURE,
|
|
73
|
+
severity=Severity.HIGH,
|
|
74
|
+
confidence=Confidence.HIGH,
|
|
75
|
+
description="NestJS controller directly accesses database models or ORM repositories rather than delegating to an injected service.",
|
|
76
|
+
impact="Breaks NestJS modular architecture, makes controllers tightly coupled to the database schema, and hampers testing.",
|
|
77
|
+
recommendation="Delegate database operations to an injected service class.",
|
|
78
|
+
suggested_fix="Inject UserService in the constructor and call this.userService.getUserById(id).",
|
|
79
|
+
framework="nestjs",
|
|
80
|
+
language="typescript",
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def evaluate(self, context: AnalysisContext) -> List[Finding]:
|
|
85
|
+
findings: List[Finding] = []
|
|
86
|
+
ts_files = context.get_files_for_language("typescript")
|
|
87
|
+
|
|
88
|
+
for f in ts_files:
|
|
89
|
+
rel_file = context.get_relative_path(f)
|
|
90
|
+
if not rel_file.endswith(".controller.ts"):
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
lines = context.get_lines(f)
|
|
94
|
+
for idx, line in enumerate(lines, start=1):
|
|
95
|
+
if re.search(
|
|
96
|
+
r"""\b(?:this\.(?:repository|repo|prisma|dataSource)\.[a-zA-Z0-9_]+\s*\()""",
|
|
97
|
+
line,
|
|
98
|
+
):
|
|
99
|
+
findings.append(
|
|
100
|
+
self.create_finding(
|
|
101
|
+
file=rel_file,
|
|
102
|
+
line=idx,
|
|
103
|
+
description=f"Direct database / ORM access inside controller in '{rel_file}'.",
|
|
104
|
+
evidence=line.strip(),
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
return findings
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@register_analyzer
|
|
111
|
+
class NestJsFrameworkAnalyzer(BaseAnalyzer):
|
|
112
|
+
name = "nestjs-framework"
|
|
113
|
+
description = (
|
|
114
|
+
"Framework analysis for NestJS: dependency injection, modules, and controller boundaries"
|
|
115
|
+
)
|
|
116
|
+
framework = "nestjs"
|
|
117
|
+
language = "typescript"
|
|
118
|
+
|
|
119
|
+
def get_rules(self) -> List[BaseRule]:
|
|
120
|
+
return [NestJsMissingInjectableRule(), NestJsDirectDbInControllerRule()]
|
|
121
|
+
|
|
122
|
+
def analyze(self, context: AnalysisContext) -> List[Finding]:
|
|
123
|
+
findings: List[Finding] = []
|
|
124
|
+
for rule in self.get_rules():
|
|
125
|
+
if context.config.is_rule_enabled(rule.rule_id):
|
|
126
|
+
findings.extend(rule.evaluate(context))
|
|
127
|
+
return findings
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""JavaScript and TypeScript import dependency graph populator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict
|
|
7
|
+
|
|
8
|
+
from auditor.analyzers.languages.javascript.parser import parse_js_file
|
|
9
|
+
from auditor.core.context import AnalysisContext
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def populate_js_dependency_graph(context: AnalysisContext) -> None:
|
|
13
|
+
"""Populate DependencyGraph using parsed JS and TS files."""
|
|
14
|
+
files = context.get_files_for_language("javascript") + context.get_files_for_language(
|
|
15
|
+
"typescript"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
mod_to_file: Dict[str, Path] = {}
|
|
19
|
+
for f in files:
|
|
20
|
+
rel = context.get_relative_path(f)
|
|
21
|
+
# Normalize without extension
|
|
22
|
+
base_mod = rel
|
|
23
|
+
for ext in (".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs"):
|
|
24
|
+
if base_mod.endswith(ext):
|
|
25
|
+
base_mod = base_mod[: -len(ext)]
|
|
26
|
+
break
|
|
27
|
+
mod_to_file[base_mod] = f
|
|
28
|
+
context.dependency_graph.get_or_create_node(base_mod, f, "javascript")
|
|
29
|
+
|
|
30
|
+
for base_mod, f in mod_to_file.items():
|
|
31
|
+
data = context.get_js_structure(f)
|
|
32
|
+
if not data:
|
|
33
|
+
data = parse_js_file(f)
|
|
34
|
+
context.set_js_structure(f, data)
|
|
35
|
+
|
|
36
|
+
for imp in data.get("imports", []):
|
|
37
|
+
mod_target = imp.get("module", "")
|
|
38
|
+
if mod_target.startswith("."):
|
|
39
|
+
# Relative import resolution
|
|
40
|
+
resolved = (f.parent / mod_target).resolve()
|
|
41
|
+
rel_resolved = context.get_relative_path(resolved)
|
|
42
|
+
for candidate_mod in mod_to_file.keys():
|
|
43
|
+
if rel_resolved.startswith(candidate_mod):
|
|
44
|
+
context.dependency_graph.add_dependency(base_mod, candidate_mod)
|
|
45
|
+
break
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""JavaScript and TypeScript parsing bridge via Node.js helper with fallback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict
|
|
10
|
+
|
|
11
|
+
PARSER_SCRIPT = Path(__file__).parent / "js_ast_parser.js"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_js_file(file_path: Path) -> Dict[str, Any]:
|
|
15
|
+
"""Parse JavaScript or TypeScript file into structured AST metadata."""
|
|
16
|
+
node_bin = shutil.which("node")
|
|
17
|
+
if node_bin and PARSER_SCRIPT.is_file():
|
|
18
|
+
try:
|
|
19
|
+
res = subprocess.run(
|
|
20
|
+
[node_bin, str(PARSER_SCRIPT), str(file_path.resolve())],
|
|
21
|
+
capture_output=True,
|
|
22
|
+
text=True,
|
|
23
|
+
timeout=8,
|
|
24
|
+
check=False,
|
|
25
|
+
)
|
|
26
|
+
if res.returncode == 0 and res.stdout.strip():
|
|
27
|
+
return json.loads(res.stdout)
|
|
28
|
+
except Exception:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
# High-fidelity fallback parser in pure Python
|
|
32
|
+
return _fallback_js_parser(file_path)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _fallback_js_parser(file_path: Path) -> Dict[str, Any]:
|
|
36
|
+
"""Pure Python fallback parser extracting JS/TS structure."""
|
|
37
|
+
import re
|
|
38
|
+
|
|
39
|
+
result: Dict[str, Any] = {
|
|
40
|
+
"file": str(file_path),
|
|
41
|
+
"totalLines": 0,
|
|
42
|
+
"imports": [],
|
|
43
|
+
"exports": [],
|
|
44
|
+
"functions": [],
|
|
45
|
+
"blockingCalls": [],
|
|
46
|
+
"evalCalls": [],
|
|
47
|
+
"unhandledPromises": [],
|
|
48
|
+
"anyTypes": [],
|
|
49
|
+
"expressRoutes": [],
|
|
50
|
+
"expressErrorMiddleware": [],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
lines = file_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
55
|
+
result["totalLines"] = len(lines)
|
|
56
|
+
for idx, line in enumerate(lines, start=1):
|
|
57
|
+
trimmed = line.strip()
|
|
58
|
+
if trimmed.startswith("//") or trimmed.startswith("/*") or trimmed.startswith("*"):
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
# Imports
|
|
62
|
+
esm = re.search(r"""import\s+(?:(?:[\w*\s{},]+)\s+from\s+)?['"]([^'"]+)['"]""", line)
|
|
63
|
+
if esm:
|
|
64
|
+
result["imports"].append({"module": esm.group(1), "line": idx})
|
|
65
|
+
cjs = re.search(r"""require\s*\(\s*['"]([^'"]+)['"]\s*\)""", line)
|
|
66
|
+
if cjs:
|
|
67
|
+
result["imports"].append({"module": cjs.group(1), "line": idx})
|
|
68
|
+
|
|
69
|
+
# Blocking calls
|
|
70
|
+
if re.search(
|
|
71
|
+
r"""\b(?:fs\.readFileSync|fs\.writeFileSync|fs\.existsSync|crypto\.pbkdf2Sync|child_process\.execSync)\b""",
|
|
72
|
+
line,
|
|
73
|
+
):
|
|
74
|
+
result["blockingCalls"].append({"line": idx, "text": trimmed})
|
|
75
|
+
|
|
76
|
+
# Eval calls
|
|
77
|
+
if re.search(r"""\b(?:eval\s*\(|new\s+Function\s*\()""", line):
|
|
78
|
+
result["evalCalls"].append({"line": idx, "text": trimmed})
|
|
79
|
+
|
|
80
|
+
# Any types
|
|
81
|
+
if re.search(r"""(?::\s*any\b|<any>|as\s+any\b)""", line):
|
|
82
|
+
result["anyTypes"].append({"line": idx, "text": trimmed})
|
|
83
|
+
|
|
84
|
+
# Unhandled promise
|
|
85
|
+
if re.search(r"""\.then\s*\(""", line) and ".catch" not in line:
|
|
86
|
+
result["unhandledPromises"].append({"line": idx, "text": trimmed})
|
|
87
|
+
|
|
88
|
+
# Express routes
|
|
89
|
+
route_m = re.search(
|
|
90
|
+
r"""\b(?:app|router)\.(get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"]\s*,\s*(?:async\s*)?(?:\(([^)]*)\)|([a-zA-Z0-9_]+))""",
|
|
91
|
+
line,
|
|
92
|
+
)
|
|
93
|
+
if route_m:
|
|
94
|
+
result["expressRoutes"].append(
|
|
95
|
+
{
|
|
96
|
+
"method": route_m.group(1),
|
|
97
|
+
"path": route_m.group(2),
|
|
98
|
+
"isAsync": "async" in line,
|
|
99
|
+
"line": idx,
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Express 4-argument error middleware: (err, req, res, next)
|
|
104
|
+
if re.search(
|
|
105
|
+
r"""\b(?:app|router)\.use\s*\(\s*(?:async\s*)?\(\s*[\w$]+\s*,\s*[\w$]+\s*,\s*[\w$]+\s*,\s*[\w$]+\s*\)""",
|
|
106
|
+
line,
|
|
107
|
+
):
|
|
108
|
+
result["expressErrorMiddleware"].append({"line": idx, "text": trimmed})
|
|
109
|
+
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
return result
|