@sun-asterisk/sunlint 1.3.47 → 1.3.49
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/config/rules/rules-registry-generated.json +1717 -282
- package/core/architecture-integration.js +57 -15
- package/core/cli-action-handler.js +51 -36
- package/core/config-manager.js +6 -0
- package/core/config-merger.js +33 -0
- package/core/config-validator.js +37 -2
- package/core/file-targeting-service.js +148 -15
- package/core/init-command.js +118 -70
- package/core/output-service.js +12 -3
- package/core/project-detector.js +517 -0
- package/core/scoring-service.js +12 -6
- package/core/summary-report-service.js +9 -4
- package/core/tui-select.js +245 -0
- package/engines/arch-detect/rules/layered/l001-presentation-layer.js +7 -15
- package/engines/arch-detect/rules/layered/l002-business-layer.js +7 -15
- package/engines/arch-detect/rules/layered/l003-data-layer.js +7 -15
- package/engines/arch-detect/rules/layered/l004-model-layer.js +7 -15
- package/engines/arch-detect/rules/layered/l005-layer-separation.js +22 -2
- package/engines/arch-detect/rules/layered/l006-dependency-direction.js +8 -5
- package/engines/arch-detect/rules/modular/m005-no-deep-imports.js +67 -29
- package/engines/arch-detect/rules/presentation/pr001-view-layer.js +16 -9
- package/engines/arch-detect/rules/presentation/pr006-router-layer.js +33 -8
- package/engines/arch-detect/rules/presentation/pr007-interactor-layer.js +35 -6
- package/engines/arch-detect/rules/project-scanner/ps003-framework-detection.js +56 -10
- package/engines/impact/cli.js +54 -39
- package/engines/impact/config/default-config.js +105 -5
- package/engines/impact/core/impact-analyzer.js +12 -15
- package/engines/impact/core/utils/gitignore-parser.js +123 -0
- package/engines/impact/core/utils/method-call-graph.js +272 -87
- package/origin-rules/dart-en.md +1 -1
- package/origin-rules/go-en.md +231 -0
- package/origin-rules/php-en.md +107 -0
- package/origin-rules/python-en.md +113 -0
- package/origin-rules/ruby-en.md +607 -0
- package/package.json +1 -1
- package/scripts/copy-arch-detect.js +5 -1
- package/scripts/copy-impact-analyzer.js +5 -1
- package/scripts/generate-rules-registry.js +30 -14
- package/skill-assets/sunlint-code-quality/SKILL.md +3 -2
- package/skill-assets/sunlint-code-quality/rules/dart/C006-verb-noun-functions.md +45 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C013-no-dead-code.md +53 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C014-dependency-injection.md +92 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C017-no-constructor-logic.md +62 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C018-generic-errors.md +57 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C019-error-log-level.md +50 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C020-no-unused-imports.md +46 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C022-no-unused-variables.md +50 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C023-no-duplicate-names.md +56 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C024-centralize-constants.md +75 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C029-catch-log-root-cause.md +53 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C030-custom-error-classes.md +86 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C033-separate-data-access.md +90 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C035-error-context-logging.md +62 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C041-no-hardcoded-secrets.md +75 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C042-boolean-naming.md +73 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C052-widget-parsing.md +84 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C060-superclass-logic.md +91 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C067-no-hardcoded-config.md +108 -0
- package/skill-assets/sunlint-code-quality/rules/go/G001-explicit-error-handling.md +53 -0
- package/skill-assets/sunlint-code-quality/rules/go/G002-context-first-argument.md +44 -0
- package/skill-assets/sunlint-code-quality/rules/go/G003-receiver-consistency.md +38 -0
- package/skill-assets/sunlint-code-quality/rules/go/G004-avoid-panic.md +49 -0
- package/skill-assets/sunlint-code-quality/rules/go/G005-goroutine-leak-prevention.md +49 -0
- package/skill-assets/sunlint-code-quality/rules/go/G006-interface-consumer-definition.md +45 -0
- package/skill-assets/sunlint-code-quality/rules/go/GN001-gin-binding-validation.md +57 -0
- package/skill-assets/sunlint-code-quality/rules/go/GN002-gin-error-response.md +48 -0
- package/skill-assets/sunlint-code-quality/rules/go/GN003-graceful-shutdown.md +57 -0
- package/skill-assets/sunlint-code-quality/rules/go/GN004-gin-route-logical-grouping.md +54 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/AGENTS.md +149 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN001-abort-after-response.md +75 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN002-request-context.md +64 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN003-bind-error-handling.md +70 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN004-dependency-injection.md +78 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN005-route-groups-middleware.md +71 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN006-http-status-codes.md +91 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN007-release-mode.md +64 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN008-struct-validation-tags.md +90 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN009-recovery-middleware.md +68 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN010-context-scope.md +68 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN011-middleware-concerns.md +92 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN012-no-log-sensitive.md +84 -0
- package/skill-assets/sunlint-code-quality/rules/java/J001-try-with-resources.md +86 -0
- package/skill-assets/sunlint-code-quality/rules/java/J002-equals-and-hashcode.md +88 -0
- package/skill-assets/sunlint-code-quality/rules/java/J003-string-comparison.md +72 -0
- package/skill-assets/sunlint-code-quality/rules/java/J004-use-java-time.md +91 -0
- package/skill-assets/sunlint-code-quality/rules/java/J005-no-print-stack-trace.md +80 -0
- package/skill-assets/sunlint-code-quality/rules/java/J006-no-system-println.md +89 -0
- package/skill-assets/sunlint-code-quality/rules/java/J007-proper-logger.md +91 -0
- package/skill-assets/sunlint-code-quality/rules/java/J008-thread-safe-singleton.md +119 -0
- package/skill-assets/sunlint-code-quality/rules/java/J009-utility-class-constructor.md +82 -0
- package/skill-assets/sunlint-code-quality/rules/java/J010-preserve-stack-trace.md +119 -0
- package/skill-assets/sunlint-code-quality/rules/java/J011-null-safe-compare.md +88 -0
- package/skill-assets/sunlint-code-quality/rules/java/J012-use-enum-collections.md +104 -0
- package/skill-assets/sunlint-code-quality/rules/java/J013-return-empty-not-null.md +102 -0
- package/skill-assets/sunlint-code-quality/rules/java/J014-hardcoded-crypto-key.md +108 -0
- package/skill-assets/sunlint-code-quality/rules/java/J015-optional-instead-of-null.md +109 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/AGENTS.md +124 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV001-form-request-validation.md +64 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV002-eager-load-no-n-plus-1.md +58 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV003-config-not-env.md +54 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV004-fillable-mass-assignment.md +51 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV005-policies-gates-authorization.md +71 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV006-queue-heavy-tasks.md +68 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV007-hash-passwords.md +51 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV008-route-model-binding.md +67 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV009-api-resources.md +72 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV010-chunk-large-datasets.md +58 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV011-db-transactions.md +73 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV012-service-layer.md +78 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV013-testing-factories.md +75 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV014-service-container.md +61 -0
- package/skill-assets/sunlint-code-quality/rules/python/P001-mutable-default-argument.md +55 -0
- package/skill-assets/sunlint-code-quality/rules/python/P002-specify-file-encoding.md +45 -0
- package/skill-assets/sunlint-code-quality/rules/python/P003-context-manager-for-resources.md +54 -0
- package/skill-assets/sunlint-code-quality/rules/python/P004-no-bare-except.md +65 -0
- package/skill-assets/sunlint-code-quality/rules/python/P005-use-isinstance.md +60 -0
- package/skill-assets/sunlint-code-quality/rules/python/P006-timezone-aware-datetime.md +58 -0
- package/skill-assets/sunlint-code-quality/rules/python/P007-use-pathlib.md +62 -0
- package/skill-assets/sunlint-code-quality/rules/python/P008-no-wildcard-import.md +52 -0
- package/skill-assets/sunlint-code-quality/rules/python/P009-logging-lazy-format.md +50 -0
- package/skill-assets/sunlint-code-quality/rules/python/P010-exception-chaining.md +57 -0
- package/skill-assets/sunlint-code-quality/rules/python/P011-subprocess-check.md +59 -0
- package/skill-assets/sunlint-code-quality/rules/python/P012-requests-timeout.md +70 -0
- package/skill-assets/sunlint-code-quality/rules/python/P013-no-global-statement.md +73 -0
- package/skill-assets/sunlint-code-quality/rules/python/P014-no-modify-collection-while-iterating.md +66 -0
- package/skill-assets/sunlint-code-quality/rules/python/P015-prefer-fstrings.md +61 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/AGENTS.md +121 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR001-strong-parameters.md +55 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR002-eager-load-includes.md +51 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR003-service-objects.md +99 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR004-active-job-background.md +67 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR005-pagination.md +53 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR006-find-each-batches.md +53 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR007-http-status-codes.md +76 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR008-before-action-auth.md +77 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR009-rails-credentials.md +61 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR010-scopes.md +57 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR011-counter-cache.md +59 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR012-render-json-status.md +42 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C006-verb-noun-functions.md +37 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C013-no-dead-code.md +55 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C014-dependency-injection.md +69 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C017-no-constructor-logic.md +66 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C018-generic-errors.md +64 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C019-error-log-level.md +64 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C020-no-unused-imports.md +47 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C022-no-unused-variables.md +46 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C023-no-duplicate-names.md +55 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C024-centralize-constants.md +68 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C029-catch-log-root-cause.md +69 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C030-custom-error-classes.md +77 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C033-separate-data-access.md +89 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C035-error-context-logging.md +66 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C041-no-hardcoded-secrets.md +65 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C042-boolean-naming.md +60 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C052-controller-parsing.md +67 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C060-superclass-logic.md +95 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C067-no-hardcoded-config.md +80 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S003-sql-injection.md +65 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S004-no-log-credentials.md +67 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S005-server-authorization.md +73 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S006-default-credentials.md +76 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S007-output-encoding.md +96 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S009-approved-crypto.md +86 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S010-csprng.md +71 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S011-insecure-deserialization.md +74 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S012-secrets-management.md +81 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S013-tls-connections.md +67 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S017-parameterized-queries.md +86 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S019-session-management.md +131 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S020-kvc-injection.md +91 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S025-input-validation.md +125 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S029-brute-force-protection.md +120 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S036-path-traversal.md +102 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S039-tls-certificate-validation.md +109 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S041-logout-invalidation.md +103 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S043-password-hashing.md +116 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S044-critical-changes-reauth.md +145 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S045-debug-info-exposure.md +116 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S046-unvalidated-redirect.md +140 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S051-token-expiry.md +134 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S053-jwt-validation.md +139 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S059-background-snapshot-protection.md +113 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S060-data-protection-api.md +106 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S061-jailbreak-detection.md +132 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Use Lazy Formatting in Logging Calls
|
|
3
|
+
impact: MEDIUM
|
|
4
|
+
impactDescription: Eager string interpolation in logging (f-strings or %) evaluates and allocates the string even when the log level is disabled, wasting CPU and memory in production.
|
|
5
|
+
tags: python, logging, performance, quality
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Use Lazy Formatting in Logging Calls
|
|
9
|
+
|
|
10
|
+
Python's `logging` module accepts a format string and arguments **separately**. The string interpolation only happens if the message will actually be emitted (i.e., the log level is enabled). Using f-strings or `%` interpolation beforehand evaluates the string unconditionally, even if `DEBUG` is disabled.
|
|
11
|
+
|
|
12
|
+
**Incorrect:**
|
|
13
|
+
```python
|
|
14
|
+
import logging
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
user_id = get_user_id()
|
|
19
|
+
payload = serialize(data)
|
|
20
|
+
|
|
21
|
+
# These always evaluate the f-string / % formatting, even if DEBUG is off
|
|
22
|
+
logger.debug(f"Processing user {user_id} with payload {payload}")
|
|
23
|
+
logger.info("Request from %s: %s" % (ip_address, request.path))
|
|
24
|
+
logger.warning("Slow query: " + str(query_time) + "ms")
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**Correct:**
|
|
28
|
+
```python
|
|
29
|
+
import logging
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
# Formatting deferred — only if DEBUG is enabled
|
|
34
|
+
logger.debug("Processing user %s with payload %s", user_id, payload)
|
|
35
|
+
logger.info("Request from %s: %s", ip_address, request.path)
|
|
36
|
+
logger.warning("Slow query: %sms", query_time)
|
|
37
|
+
|
|
38
|
+
# For complex objects, use lazy repr via %r
|
|
39
|
+
logger.debug("Event received: %r", event)
|
|
40
|
+
|
|
41
|
+
# Exception info — use exc_info=True or logger.exception()
|
|
42
|
+
try:
|
|
43
|
+
call_external_api()
|
|
44
|
+
except requests.RequestException as e:
|
|
45
|
+
logger.error("External API failed for user %s: %s", user_id, e, exc_info=True)
|
|
46
|
+
# or equivalently inside an except block:
|
|
47
|
+
# logger.exception("External API failed for user %s", user_id)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**Tools:** Ruff `G004` (logging-f-string), `W1201` in Pylint (logging-not-lazy), `W1202` (logging-format-interpolation), `TRY400` (error-instead-of-exception), flake8-logging-format
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Use raise...from to Chain Exceptions
|
|
3
|
+
impact: MEDIUM
|
|
4
|
+
impactDescription: Rethrowing an exception without raise...from loses the original traceback, making it impossible to diagnose the root cause in production logs.
|
|
5
|
+
tags: python, exceptions, error-handling, traceability, quality
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Use raise...from to Chain Exceptions
|
|
9
|
+
|
|
10
|
+
When catching an exception and raising a different (or more specific) one, use `raise NewException("msg") from original_exception` to preserve the full causal chain. Without `from`, Python 3 still shows an implicit chain, but the context can be confusing. Using `from` makes the chain explicit and intentional.
|
|
11
|
+
|
|
12
|
+
Using `raise ... from None` deliberately suppresses the original context when it adds no value to the end user.
|
|
13
|
+
|
|
14
|
+
**Incorrect:**
|
|
15
|
+
```python
|
|
16
|
+
import json
|
|
17
|
+
|
|
18
|
+
def load_config(path: str) -> dict:
|
|
19
|
+
try:
|
|
20
|
+
with open(path, encoding="utf-8") as f:
|
|
21
|
+
return json.load(f)
|
|
22
|
+
except json.JSONDecodeError:
|
|
23
|
+
raise ValueError("Config file is malformed") # original traceback lost
|
|
24
|
+
|
|
25
|
+
def get_user(user_id: int) -> dict:
|
|
26
|
+
try:
|
|
27
|
+
return db.find_by_id(user_id)
|
|
28
|
+
except DatabaseError:
|
|
29
|
+
raise RuntimeError("Failed to fetch user") # loses DB error context
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**Correct:**
|
|
33
|
+
```python
|
|
34
|
+
import json
|
|
35
|
+
|
|
36
|
+
def load_config(path: str) -> dict:
|
|
37
|
+
try:
|
|
38
|
+
with open(path, encoding="utf-8") as f:
|
|
39
|
+
return json.load(f)
|
|
40
|
+
except json.JSONDecodeError as e:
|
|
41
|
+
raise ValueError(f"Config file '{path}' is malformed") from e # chain preserved
|
|
42
|
+
|
|
43
|
+
def get_user(user_id: int) -> dict:
|
|
44
|
+
try:
|
|
45
|
+
return db.find_by_id(user_id)
|
|
46
|
+
except DatabaseError as e:
|
|
47
|
+
raise UserNotFoundError(f"User {user_id} could not be fetched") from e
|
|
48
|
+
|
|
49
|
+
# Suppress context intentionally (e.g., to hide internal DB details from callers)
|
|
50
|
+
def validate_token(token: str) -> None:
|
|
51
|
+
try:
|
|
52
|
+
jwt.decode(token, SECRET)
|
|
53
|
+
except jwt.ExpiredSignatureError:
|
|
54
|
+
raise AuthenticationError("Token has expired") from None
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**Tools:** Ruff `W0707` / `raise-missing-from`, `TRY200` (reraise-no-cause), `B904` (raise-without-from-inside-except), Pylint `W0707`, flake8-bugbear
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Pass Explicit check= to subprocess.run
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: subprocess.run silently ignores non-zero exit codes by default; a failed command goes undetected and subsequent code runs on corrupt or missing output.
|
|
5
|
+
tags: python, subprocess, error-handling, quality, security
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Pass Explicit check= to subprocess.run
|
|
9
|
+
|
|
10
|
+
`subprocess.run()` has `check=False` by default, meaning a command that exits with a non-zero status (indicating failure) does not raise an exception. Code that follows assumes success and may operate on missing or corrupt output without any error ever being raised.
|
|
11
|
+
|
|
12
|
+
Always pass `check=True` unless you explicitly intend to handle failures yourself by inspecting `returncode`.
|
|
13
|
+
|
|
14
|
+
**Incorrect:**
|
|
15
|
+
```python
|
|
16
|
+
import subprocess
|
|
17
|
+
|
|
18
|
+
# Exit code ignored — if ffmpeg fails, output file doesn't exist
|
|
19
|
+
subprocess.run(["ffmpeg", "-i", "input.mp4", "output.mp4"])
|
|
20
|
+
|
|
21
|
+
# result.returncode is never checked
|
|
22
|
+
result = subprocess.run(["git", "pull"])
|
|
23
|
+
print("Done") # runs even if git pull failed
|
|
24
|
+
|
|
25
|
+
# capture_output=True but still no check
|
|
26
|
+
result = subprocess.run(
|
|
27
|
+
["python", "migrate.py"],
|
|
28
|
+
capture_output=True,
|
|
29
|
+
text=True,
|
|
30
|
+
)
|
|
31
|
+
apply_migrations() # runs even if migrate.py failed
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**Correct:**
|
|
35
|
+
```python
|
|
36
|
+
import subprocess
|
|
37
|
+
|
|
38
|
+
# Raises CalledProcessError if command fails
|
|
39
|
+
subprocess.run(
|
|
40
|
+
["ffmpeg", "-i", "input.mp4", "output.mp4"],
|
|
41
|
+
check=True,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Or capture output and check explicitly
|
|
45
|
+
result = subprocess.run(
|
|
46
|
+
["python", "migrate.py"],
|
|
47
|
+
capture_output=True,
|
|
48
|
+
text=True,
|
|
49
|
+
check=True, # raises on non-zero exit code
|
|
50
|
+
)
|
|
51
|
+
apply_migrations() # only reached if migration succeeded
|
|
52
|
+
|
|
53
|
+
# When you WANT to handle failures yourself — explicit intent:
|
|
54
|
+
result = subprocess.run(["git", "pull"], check=False)
|
|
55
|
+
if result.returncode != 0:
|
|
56
|
+
logger.warning("git pull failed with code %d", result.returncode)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**Tools:** Ruff `PLW1510` (subprocess-run-without-check), Pylint `W1510`, Bandit `S603`, flake8-bugbear
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Always Set timeout for HTTP Requests
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: HTTP requests without a timeout can hang indefinitely, exhausting thread pools and connection pools, causing cascading failures and outages in production services.
|
|
5
|
+
tags: python, requests, http, reliability, quality
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Always Set timeout for HTTP Requests
|
|
9
|
+
|
|
10
|
+
The `requests` library and similar HTTP clients have **no default timeout**. A single slow or unresponsive server can cause a thread to block indefinitely, exhausting thread pools and causing the entire service to become unresponsive. Always set an explicit `timeout` on every outbound HTTP call.
|
|
11
|
+
|
|
12
|
+
**Incorrect:**
|
|
13
|
+
```python
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
# Hangs forever if server is unresponsive
|
|
17
|
+
response = requests.get("https://api.example.com/data")
|
|
18
|
+
|
|
19
|
+
# Also no timeout
|
|
20
|
+
session = requests.Session()
|
|
21
|
+
response = session.post(
|
|
22
|
+
"https://api.example.com/webhook",
|
|
23
|
+
json=payload,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# httpx — same risk
|
|
27
|
+
import httpx
|
|
28
|
+
response = httpx.get("https://api.example.com/items")
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**Correct:**
|
|
32
|
+
```python
|
|
33
|
+
import requests
|
|
34
|
+
|
|
35
|
+
# Set both connect and read timeouts as a tuple
|
|
36
|
+
response = requests.get(
|
|
37
|
+
"https://api.example.com/data",
|
|
38
|
+
timeout=(3.05, 27), # (connect_timeout, read_timeout) in seconds
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Or a single value for both
|
|
42
|
+
response = requests.post(
|
|
43
|
+
"https://api.example.com/webhook",
|
|
44
|
+
json=payload,
|
|
45
|
+
timeout=10, # applies to both connect and read
|
|
46
|
+
)
|
|
47
|
+
response.raise_for_status()
|
|
48
|
+
|
|
49
|
+
# httpx — also requires explicit timeout
|
|
50
|
+
import httpx
|
|
51
|
+
|
|
52
|
+
with httpx.Client(timeout=httpx.Timeout(connect=3.0, read=30.0)) as client:
|
|
53
|
+
response = client.get("https://api.example.com/items")
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Configure timeouts at the session level:**
|
|
57
|
+
```python
|
|
58
|
+
# Apply uniformly to all requests from the session
|
|
59
|
+
session = requests.Session()
|
|
60
|
+
adapter = requests.adapters.HTTPAdapter(max_retries=3)
|
|
61
|
+
session.mount("https://", adapter)
|
|
62
|
+
|
|
63
|
+
DEFAULT_TIMEOUT = (3.05, 27)
|
|
64
|
+
|
|
65
|
+
def get_with_timeout(url: str, **kwargs) -> requests.Response:
|
|
66
|
+
kwargs.setdefault("timeout", DEFAULT_TIMEOUT)
|
|
67
|
+
return session.get(url, **kwargs)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Tools:** Ruff `W3101` (missing-timeout), Pylint `W3101`, Bandit `S113`, flake8
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Avoid global Statement in Functions
|
|
3
|
+
impact: MEDIUM
|
|
4
|
+
impactDescription: The global statement creates hidden dependencies between functions and module-level state, making code order-dependent, untestable, and unsafe for concurrent use.
|
|
5
|
+
tags: python, global, state, quality, concurrency
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Avoid global Statement in Functions
|
|
9
|
+
|
|
10
|
+
Using `global var` inside a function creates an implicit dependency on module-level mutable state. The function's behavior changes depending on which other functions have already run, making it nearly impossible to test in isolation. In multi-threaded or async contexts, it introduces race conditions.
|
|
11
|
+
|
|
12
|
+
Pass state explicitly through parameters and return values, or encapsulate it in a class.
|
|
13
|
+
|
|
14
|
+
**Incorrect:**
|
|
15
|
+
```python
|
|
16
|
+
counter = 0
|
|
17
|
+
last_user = None
|
|
18
|
+
|
|
19
|
+
def process_request(user_id: int) -> None:
|
|
20
|
+
global counter, last_user # hidden mutation of module state
|
|
21
|
+
counter += 1
|
|
22
|
+
last_user = user_id
|
|
23
|
+
do_work(user_id)
|
|
24
|
+
|
|
25
|
+
def get_stats() -> dict:
|
|
26
|
+
return {"total": counter, "last": last_user}
|
|
27
|
+
|
|
28
|
+
# Tests are order-dependent and cannot run in parallel
|
|
29
|
+
def test_process_request():
|
|
30
|
+
process_request(42)
|
|
31
|
+
assert counter == 1 # depends on no other test having run first
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**Correct:**
|
|
35
|
+
```python
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from threading import Lock
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class RequestTracker:
|
|
41
|
+
_count: int = 0
|
|
42
|
+
_last_user: int | None = None
|
|
43
|
+
_lock: Lock = field(default_factory=Lock, compare=False)
|
|
44
|
+
|
|
45
|
+
def record(self, user_id: int) -> None:
|
|
46
|
+
with self._lock:
|
|
47
|
+
self._count += 1
|
|
48
|
+
self._last_user = user_id
|
|
49
|
+
|
|
50
|
+
def stats(self) -> dict:
|
|
51
|
+
return {"total": self._count, "last": self._last_user}
|
|
52
|
+
|
|
53
|
+
tracker = RequestTracker()
|
|
54
|
+
|
|
55
|
+
def process_request(user_id: int, tracker: RequestTracker) -> None:
|
|
56
|
+
tracker.record(user_id)
|
|
57
|
+
do_work(user_id)
|
|
58
|
+
|
|
59
|
+
# Tests are isolated
|
|
60
|
+
def test_process_request():
|
|
61
|
+
t = RequestTracker()
|
|
62
|
+
process_request(42, t)
|
|
63
|
+
assert t.stats()["total"] == 1
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**Acceptable module-level constants (no `global` needed):**
|
|
67
|
+
```python
|
|
68
|
+
# Constants never mutated — global statement not needed or used
|
|
69
|
+
MAX_RETRIES = 3
|
|
70
|
+
DEFAULT_TIMEOUT = 30.0
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Tools:** Ruff `PLW0603` (global-statement), Pylint `W0603`, SonarQube Python `S1854`, flake8
|
package/skill-assets/sunlint-code-quality/rules/python/P014-no-modify-collection-while-iterating.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Do Not Modify Collection While Iterating Over It
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: Adding or removing items from a list, dict, or set while iterating over it causes RuntimeError, skipped elements, or undefined behavior depending on the collection type.
|
|
5
|
+
tags: python, iteration, bugs, pitfalls, quality
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Do Not Modify Collection While Iterating Over It
|
|
9
|
+
|
|
10
|
+
Modifying a `dict` or `set` while iterating raises `RuntimeError: dictionary changed size during iteration` / `set changed size during iteration`. Modifying a `list` during iteration does **not** raise an error but silently skips or processes elements multiple times, producing wrong results.
|
|
11
|
+
|
|
12
|
+
Always iterate over a copy, build a new collection, or collect modifications and apply them after the loop.
|
|
13
|
+
|
|
14
|
+
**Incorrect:**
|
|
15
|
+
```python
|
|
16
|
+
# dict — raises RuntimeError
|
|
17
|
+
config = {"debug": True, "deprecatedKey": "val", "timeout": 30}
|
|
18
|
+
|
|
19
|
+
for key in config:
|
|
20
|
+
if key.startswith("deprecated"):
|
|
21
|
+
del config[key] # RuntimeError: dictionary changed size during iteration
|
|
22
|
+
|
|
23
|
+
# set — raises RuntimeError
|
|
24
|
+
seen = {1, 2, 3, 4, 5}
|
|
25
|
+
|
|
26
|
+
for item in seen:
|
|
27
|
+
if item % 2 == 0:
|
|
28
|
+
seen.discard(item) # RuntimeError: set changed size during iteration
|
|
29
|
+
|
|
30
|
+
# list — silent bug: skips elements
|
|
31
|
+
items = [1, 2, 2, 3, 4]
|
|
32
|
+
|
|
33
|
+
for i, item in enumerate(items):
|
|
34
|
+
if item == 2:
|
|
35
|
+
items.remove(item) # skips the second 2 silently
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Correct:**
|
|
39
|
+
```python
|
|
40
|
+
# dict — iterate over a copy of keys
|
|
41
|
+
config = {"debug": True, "deprecatedKey": "val", "timeout": 30}
|
|
42
|
+
|
|
43
|
+
for key in list(config.keys()):
|
|
44
|
+
if key.startswith("deprecated"):
|
|
45
|
+
del config[key]
|
|
46
|
+
|
|
47
|
+
# Or build a new dict with dict comprehension (preferred for clarity)
|
|
48
|
+
config = {k: v for k, v in config.items() if not k.startswith("deprecated")}
|
|
49
|
+
|
|
50
|
+
# set — iterate over a copy
|
|
51
|
+
seen = {1, 2, 3, 4, 5}
|
|
52
|
+
to_remove = {item for item in seen if item % 2 == 0}
|
|
53
|
+
seen -= to_remove
|
|
54
|
+
|
|
55
|
+
# list — collect removals, apply after loop
|
|
56
|
+
items = [1, 2, 2, 3, 4]
|
|
57
|
+
items = [item for item in items if item != 2] # list comprehension
|
|
58
|
+
|
|
59
|
+
# Or for in-place modification, iterate in reverse
|
|
60
|
+
items = [1, 2, 2, 3, 4]
|
|
61
|
+
for i in range(len(items) - 1, -1, -1):
|
|
62
|
+
if items[i] == 2:
|
|
63
|
+
del items[i]
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**Tools:** Ruff `E4702` (modified-iterating-dict), `E4703` (modified-iterating-set), `W4701` (modified-iterating-list), Pylint `modified_iteration` checker, flake8
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Prefer f-strings Over % and str.format()
|
|
3
|
+
impact: MEDIUM
|
|
4
|
+
impactDescription: f-strings introduced in Python 3.6 are more readable, faster at runtime, and catch errors at parse time rather than producing cryptic runtime errors from mismatched argument counts.
|
|
5
|
+
tags: python, fstrings, readability, performance, quality, python3
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Prefer f-strings Over % and str.format()
|
|
9
|
+
|
|
10
|
+
Python 3.6 introduced f-strings (`f"..."`) as the modern, preferred way to embed expressions in string literals. They are:
|
|
11
|
+
- **More readable** — expression and string are co-located
|
|
12
|
+
- **Faster** — evaluated at the bytecode level without function dispatch
|
|
13
|
+
- **Safer** — syntax errors caught at parse time; no mismatched `%s` args
|
|
14
|
+
- **More powerful** — support arbitrary expressions and format specs inline
|
|
15
|
+
|
|
16
|
+
**Incorrect:**
|
|
17
|
+
```python
|
|
18
|
+
name = "Alice"
|
|
19
|
+
score = 98.567
|
|
20
|
+
items = ["apple", "banana"]
|
|
21
|
+
|
|
22
|
+
# %-formatting (Python 2 style) — argument count mismatches cause runtime TypeError
|
|
23
|
+
msg = "Hello, %s! Your score is %.2f" % (name, score)
|
|
24
|
+
debug = "Processing %d items: %s" % len(items) # runtime error: int not iterable
|
|
25
|
+
|
|
26
|
+
# str.format() — verbose and error-prone with positional indices
|
|
27
|
+
msg = "Hello, {}! Your score is {:.2f}".format(name, score)
|
|
28
|
+
debug = "User {0} has {1} points ({0} is great)".format(name, score)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**Correct:**
|
|
32
|
+
```python
|
|
33
|
+
name = "Alice"
|
|
34
|
+
score = 98.567
|
|
35
|
+
user_id = 1042
|
|
36
|
+
|
|
37
|
+
# f-strings — expression evaluated inline
|
|
38
|
+
msg = f"Hello, {name}! Your score is {score:.2f}"
|
|
39
|
+
debug = f"Processing {len(items)} items: {items}"
|
|
40
|
+
|
|
41
|
+
# Supports format specs
|
|
42
|
+
padded = f"{user_id:05d}" # "01042"
|
|
43
|
+
percent = f"{score / 100:.1%}" # "98.6%"
|
|
44
|
+
repr_v = f"Object: {obj!r}" # calls repr()
|
|
45
|
+
|
|
46
|
+
# Multi-line f-string
|
|
47
|
+
report = (
|
|
48
|
+
f"User: {name}\n"
|
|
49
|
+
f"Score: {score:.2f}\n"
|
|
50
|
+
f"Rank: {get_rank(score)}"
|
|
51
|
+
)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Exception — logging calls should NOT use f-strings:**
|
|
55
|
+
```python
|
|
56
|
+
# In logging, keep lazy % formatting for performance (see P009)
|
|
57
|
+
logger.debug("Processing user %s", user_id) # correct
|
|
58
|
+
logger.debug(f"Processing user {user_id}") # incorrect — always evaluates
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Tools:** Ruff `UP031` (printf-string-formatting), `UP032` (f-string), `G004` (logging-f-string), pyupgrade, flynt
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Ruby on Rails Framework — SunLint Agent Guide
|
|
2
|
+
|
|
3
|
+
> Priority directives for AI agents working on Rails projects.
|
|
4
|
+
> Rule files: `.agent/skills/sunlint-code-quality/rules/`
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Critical Patterns — Apply Every Time
|
|
9
|
+
|
|
10
|
+
### Input Filtering
|
|
11
|
+
- **Always** whitelist params with `params.require(:model).permit(:field1, :field2)`
|
|
12
|
+
- **Never** use `params[:model].to_unsafe_h`, `params.permit!`, or `Model.create(params[:model])`
|
|
13
|
+
- See: `RR001-strong-parameters.md`
|
|
14
|
+
|
|
15
|
+
### N+1 Queries
|
|
16
|
+
- Any controller loading a collection that renders association data → add `includes(:relation)`
|
|
17
|
+
- Use `eager_load(:relation)` when filtering by association columns (SQL `WHERE`)
|
|
18
|
+
- Count in loops → use `counter_cache: true` + `model.relation_count` column
|
|
19
|
+
- See: `RR002-eager-load-includes.md`, `RR011-counter-cache.md`
|
|
20
|
+
|
|
21
|
+
### Authentication
|
|
22
|
+
- `before_action :authenticate_user!` in `ApplicationController` — opt-out for public actions with `skip_before_action`
|
|
23
|
+
- **Never** add authentication checks per-action (opt-in) — new actions are public by default
|
|
24
|
+
- See: `RR008-before-action-auth.md`
|
|
25
|
+
|
|
26
|
+
### Secrets
|
|
27
|
+
- **Never** hardcode API keys, tokens, passwords in source files
|
|
28
|
+
- `Rails.application.credentials.section[:key]` OR `ENV.fetch('KEY')` only
|
|
29
|
+
- `config/master.key` and `config/credentials/*.key` must be in `.gitignore`
|
|
30
|
+
- See: `RR009-rails-credentials.md`
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Architecture Patterns
|
|
35
|
+
|
|
36
|
+
### Controller responsibility — thin controllers only
|
|
37
|
+
Controllers do exactly: authenticate → authorize → parse strong params → call service → render response.
|
|
38
|
+
```ruby
|
|
39
|
+
def create
|
|
40
|
+
result = PlaceOrderService.new(user: current_user, params: order_params).call
|
|
41
|
+
if result.success?
|
|
42
|
+
render json: OrderResource.new(result.order), status: :created
|
|
43
|
+
else
|
|
44
|
+
render json: { errors: result.errors }, status: :unprocessable_entity
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
```
|
|
48
|
+
See: `RR003-service-objects.md`
|
|
49
|
+
|
|
50
|
+
### Service Objects
|
|
51
|
+
- Business logic with multi-model writes, external calls, or branching → `app/services/` PORO
|
|
52
|
+
- Expose one public method: `#call`
|
|
53
|
+
- Return a `Result` struct: `Result = Struct.new(:value, :success?, :error, keyword_init: true)`
|
|
54
|
+
- See: `RR003-service-objects.md`
|
|
55
|
+
|
|
56
|
+
### Background Jobs
|
|
57
|
+
- Email, external API calls, file processing, report generation → `ActiveJob` + `perform_later`
|
|
58
|
+
- **Never** `Thread.new` in controllers, **never** inline slow work in the request cycle
|
|
59
|
+
- Pass only primitive/serializable arguments to jobs (IDs, strings) — not AR objects
|
|
60
|
+
- See: `RR004-active-job-background.md`
|
|
61
|
+
|
|
62
|
+
### Pagination
|
|
63
|
+
- Every collection endpoint must call `.page(params[:page]).per(n)` (Kaminari) or `pagy(...)` (Pagy)
|
|
64
|
+
- **Never** `Model.all` without a page limit returned to the client
|
|
65
|
+
- See: `RR005-pagination.md`
|
|
66
|
+
|
|
67
|
+
### Bulk Data Iteration (jobs/scripts/rake)
|
|
68
|
+
- `Model.all.each` → replace with `Model.find_each(batch_size: 500)`
|
|
69
|
+
- Bulk updates → `Model.in_batches(of: 200) { |batch| batch.update_all(...) }`
|
|
70
|
+
- See: `RR006-find-each-batches.md`
|
|
71
|
+
|
|
72
|
+
### Query Scopes
|
|
73
|
+
- Named conditions (`published`, `recent`, `by_author`) → `scope :name, -> { where(...) }` on model
|
|
74
|
+
- **Never** inline `where('...')` chains in controllers — define a scope, call the scope
|
|
75
|
+
- See: `RR010-scopes.md`
|
|
76
|
+
|
|
77
|
+
### HTTP Status Codes
|
|
78
|
+
- Every `render json:` must include `status:` — use Rails symbol names
|
|
79
|
+
- `POST` success → `:created` (201), validation failure → `:unprocessable_entity` (422)
|
|
80
|
+
- DELETE with no body → `head :no_content` (204)
|
|
81
|
+
- Centralize `rescue_from ActiveRecord::RecordNotFound` → `:not_found` in ApplicationController
|
|
82
|
+
- See: `RR007-http-status-codes.md`, `RR012-render-json-status.md`
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## Run Commands
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
rails generate model ModelName field:type
|
|
90
|
+
rails generate service OrderService # custom generator if installed
|
|
91
|
+
rails generate job ProcessExportJob
|
|
92
|
+
rails generate migration AddCounterToTable
|
|
93
|
+
|
|
94
|
+
rails test test/models/post_test.rb # run specific test file
|
|
95
|
+
rails test -n /test_name_pattern/ # run by name match
|
|
96
|
+
bundle exec rspec spec/services/ # RSpec equivalent
|
|
97
|
+
|
|
98
|
+
rails credentials:edit --environment production # edit secrets
|
|
99
|
+
rails credentials:show # view decrypted
|
|
100
|
+
|
|
101
|
+
bundle exec rubocop --autocorrect # lint + fix
|
|
102
|
+
bundle exec brakeman # security scanner
|
|
103
|
+
bundle exec bundle-audit check # dependency CVE scanner
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## What NOT to do (quick reference)
|
|
109
|
+
|
|
110
|
+
| ❌ Wrong | ✅ Correct |
|
|
111
|
+
|---|---|
|
|
112
|
+
| `params[:user].to_unsafe_h` | `params.require(:user).permit(:name, :email)` |
|
|
113
|
+
| `Model.create(params[:model])` | `Model.create(user_params)` (permitted) |
|
|
114
|
+
| `@posts = Post.all` (unbounded) | `@posts = Post.published.page(params[:page])` |
|
|
115
|
+
| `post.comments.count` in loop | `counter_cache: true` → `post.comments_count` |
|
|
116
|
+
| `Thread.new { ... }` | `MyJob.perform_later(...)` |
|
|
117
|
+
| `after_create :call_stripe` on model | Service object wrapping the transaction |
|
|
118
|
+
| Auth check per action (opt-in) | `before_action :authenticate_user!` in ApplicationController |
|
|
119
|
+
| `render json: { errors: ... }` | `render json: { errors: ... }, status: :unprocessable_entity` |
|
|
120
|
+
| `API_KEY = 'sk_live_...'` in source | `Rails.application.credentials.stripe[:api_key]` |
|
|
121
|
+
| Inline `where('published = true')` in controller | `scope :published, -> { where(published: true) }` |
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "RR001 – Use Strong Parameters (params.require().permit())"
|
|
3
|
+
impact: high
|
|
4
|
+
impactDescription: "Mass assignment via permit() prevents unfiltered user input from modifying forbidden attributes such as roles, admin flags, or foreign keys."
|
|
5
|
+
tags: [ruby, rails, security, input-validation]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# RR001 – Use Strong Parameters
|
|
9
|
+
|
|
10
|
+
## Rule
|
|
11
|
+
|
|
12
|
+
Always whitelist params with `require().permit()`. Never permit all params or bypass strong parameters.
|
|
13
|
+
|
|
14
|
+
## Why
|
|
15
|
+
|
|
16
|
+
`params.to_unsafe_h` and raw `params` bypass Rails' Mass Assignment protection. An attacker can inject `admin: true`, `role: 'admin'`, or arbitrary foreign keys.
|
|
17
|
+
|
|
18
|
+
## Wrong
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
# Bypasses strong parameters entirely
|
|
22
|
+
def create
|
|
23
|
+
@user = User.create(params[:user].to_unsafe_h)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Permits everything
|
|
27
|
+
def user_params
|
|
28
|
+
params.require(:user).permit!
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Missing permit — may raise ForbiddenAttributesError or pass raw hash
|
|
32
|
+
def create
|
|
33
|
+
@user = User.create(user_params: params[:user])
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Correct
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
def create
|
|
41
|
+
@user = User.create!(user_params)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def user_params
|
|
47
|
+
params.require(:user).permit(:name, :email, :password, :password_confirmation)
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Notes
|
|
52
|
+
|
|
53
|
+
- `permit!` is only acceptable in internal admin scripts where input is completely trusted and audited.
|
|
54
|
+
- Nested attributes need explicit permit: `permit(:name, addresses_attributes: [:street, :city])`.
|
|
55
|
+
- Never store the result of `to_unsafe_h` into a model.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "RR002 – Prevent N+1 with includes/eager_load"
|
|
3
|
+
impact: high
|
|
4
|
+
impactDescription: "Unaddressed N+1 queries grow linearly with page size; a 100-row page fires 101 queries instead of 2."
|
|
5
|
+
tags: [ruby, rails, performance, database]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# RR002 – Prevent N+1 with includes / eager_load
|
|
9
|
+
|
|
10
|
+
## Rule
|
|
11
|
+
|
|
12
|
+
Always eager-load associations when iterating over a collection. Use `includes`, `eager_load`, or `preload` based on whether a WHERE on the association is needed.
|
|
13
|
+
|
|
14
|
+
## Why
|
|
15
|
+
|
|
16
|
+
Accessing `post.author` inside an `.each` loop fires one SELECT per record — the classic N+1. Rails provides declarative eager loading to collapse N+1 into one extra query.
|
|
17
|
+
|
|
18
|
+
## Wrong
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
# N+1: fires 1 + N queries for authors
|
|
22
|
+
@posts = Post.all
|
|
23
|
+
# view iterates: @posts.each { |p| p.author.name }
|
|
24
|
+
|
|
25
|
+
# N+1: fires 1 + N queries for comment counts
|
|
26
|
+
@posts = Post.all
|
|
27
|
+
# view: post.comments.count inside loop
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Correct
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
# Two queries total: one for posts, one for authors
|
|
34
|
+
@posts = Post.includes(:author).all
|
|
35
|
+
|
|
36
|
+
# Three queries: posts + authors + tags
|
|
37
|
+
@posts = Post.includes(:author, :tags).page(params[:page])
|
|
38
|
+
|
|
39
|
+
# Need to filter by association column → use eager_load (LEFT JOIN)
|
|
40
|
+
@posts = Post.eager_load(:author).where(authors: { verified: true })
|
|
41
|
+
|
|
42
|
+
# Avoid count N+1 with counter_cache or withCount equivalent
|
|
43
|
+
@posts = Post.includes(:comments)
|
|
44
|
+
# or add counter_cache: true to the belongs_to and use post.comments_count
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Notes
|
|
48
|
+
|
|
49
|
+
- `includes` automatically selects between `preload` (separate queries) and `eager_load` (LEFT JOIN). Prefer `includes` unless you explicitly need one over the other.
|
|
50
|
+
- Use the `bullet` gem in development to surface N+1 automatically.
|
|
51
|
+
- Nested associations: `Post.includes(comments: :author)`.
|