diff --git a/CHANGELOG.md b/CHANGELOG.md
index c5a0f41..9491cbe 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.1.0-dev.6] - 2025-10-23
+
+### Breaking Changes
+- Configuration format converted from kebab-case to snake_case to align with Dart's `analysis_options.yaml` conventions
+
+### Added
+- Comprehensive suppression system for ignoring specific rules or lines during analysis
+
+### Changed
+- Test reorganization plan and improved test coverage
+
+### Tests
+- Added comprehensive unit tests for suppression system
+- Enhanced test coverage for models and enums
+
## [0.1.0-dev.5] - 2025-10-09
### Added
diff --git a/README.md b/README.md
index 446186a..bbf1f1c 100644
--- a/README.md
+++ b/README.md
@@ -2,8 +2,11 @@
-
-
+
Dart-based security-focused code analyzer which analyzes your Dart code for potential security flaws.
@@ -16,8 +19,7 @@
> 🚧 UNDER CONSTRUCTION 🚧
>
-> Please note that this project is still under construction and **not yet ready for production use
-**.
+> Please note that this project is still under construction and not yet ready for production use.
>
> Full documentation will be available once the project is ready for production use. If you have
> any questions, feel free to open an issue.
@@ -38,12 +40,13 @@ is similar to what you might expect.
- Usage of insecure HTTP connections
# Installation
-
-> **Note:** dart_shield is not yet available on pub.dev.
-
To install dart_shield, run the following command:
```bash
+# Using pub.dev
+dart pub global activate dart_shield
+
+# Directly from GitHub
dart pub global activate -s git https://github.com/yardexx/dart_shield
```
@@ -115,20 +118,20 @@ shield:
# List of rules that dart_shield will use to analyze your code
rules:
- - prefer-https-over-http
- - avoid-hardcoded-secrets
+ - prefer_https_over_http
+ - avoid_hardcoded_secrets
# Some rules need more fine-tuning and are marked as experimental.
- # You can enable them by setting `enable-experimental` to `true`.
- enable-experimental: true
+ # You can enable them by setting `enable_experimental` to `true`.
+ enable_experimental: true
# List of experimental rules that dart_shield will use to analyze your code
# ⚠️ Experimental rules are subject to change and may not be as stable as regular rules.
- # ⚠️ Using "experimental-rules" without setting "enable-experimental" to "true" will cause an error.
- experimental-rules:
- - avoid-hardcoded-urls
- - avoid-weak-hashing
- - prefer-secure-random
+ # ⚠️ Using "experimental_rules" without setting "enable_experimental" to "true" will cause an error.
+ experimental_rules:
+ - avoid_hardcoded_urls
+ - avoid_weak_hashing
+ - prefer_secure_random
```
# Rules
@@ -138,11 +141,11 @@ similar to how linter rules enforce code style.
## List of rules
-- avoid-hardcoded-secrets: Detects hardcoded secrets, such as API keys and passwords.
-- avoid-hardcoded-urls: Detects hardcoded URLs.
-- prefer-https-over-http: Detects the use of insecure HTTP connections.
-- avoid-weak-hashing: Detects the use of weak hashing algorithms, such as MD5 and SHA-1.
-- prefer-secure-random: Detects the use of non-secure random number generators.
+- avoid_hardcoded_secrets: Detects hardcoded secrets, such as API keys and passwords.
+- avoid_hardcoded_urls: Detects hardcoded URLs.
+- prefer_https_over_http: Detects the use of insecure HTTP connections.
+- avoid_weak_hashing: Detects the use of weak hashing algorithms, such as MD5 and SHA-1.
+- prefer_secure_random: Detects the use of non-secure random number generators.
# Contributing
diff --git a/analysis_options.yaml b/analysis_options.yaml
index 2bf05c5..d8356b6 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -1,6 +1,8 @@
include: package:very_good_analysis/analysis_options.yaml
analyzer:
+ errors:
+ document_ignores: ignore
exclude:
- '**.g.dart'
linter:
diff --git a/example/shield_options.yaml b/example/shield_options.yaml
index 868c2fd..43f5d09 100644
--- a/example/shield_options.yaml
+++ b/example/shield_options.yaml
@@ -15,18 +15,24 @@ shield:
- '**.g.dart'
# List of rules that dart_shield will use to analyze your code
+ # You can use both simple string format and object format with exclusions
rules:
- - prefer-https-over-http
- - avoid-hardcoded-secrets
+ - prefer_https_over_http
+ - avoid_hardcoded_secrets:
+ exclude:
+ - lib/config.dart
+ - test/**
# Some rules need more fine-tuning and are marked as experimental.
- # You can enable them by setting `enable-experimental` to `true`.
- enable-experimental: true
+ # You can enable them by setting `enable_experimental` to `true`.
+ enable_experimental: true
# List of experimental rules that dart_shield will use to analyze your code
# ⚠️ Experimental rules are subject to change and may not be as stable as regular rules.
- # ⚠️ Using "experimental-rules" without setting "enable-experimental" to "true" will cause an error.
- experimental-rules:
- - avoid-hardcoded-urls
- - avoid-weak-hashing
- - prefer-secure-random
\ No newline at end of file
+ # ⚠️ Using "experimental_rules" without setting "enable_experimental" to "true" will cause an error.
+ experimental_rules:
+ - avoid_hardcoded_urls
+ - avoid_weak_hashing:
+ exclude:
+ - lib/legacy/**
+ - prefer_secure_random
\ No newline at end of file
diff --git a/lib/assets/shield_secrets_dart.dart b/lib/assets/shield_secrets_dart.dart
index 6ae0223..10d1e4e 100644
--- a/lib/assets/shield_secrets_dart.dart
+++ b/lib/assets/shield_secrets_dart.dart
@@ -1,7 +1,7 @@
// Copy of [shield_secrets.yaml] because Dart doesn't support native assets yet.
// This is a workaround until Dart SDK adds native asset support.
// See: https://github.com/dart-lang/sdk/issues/53562
-// TODO: Remove this file and use native assets when Dart SDK supports it
+// TODO(yardex): Use native assets when Dart SDK supports it
const String shieldSecretsSource = r'''
shield_patterns:
diff --git a/lib/src/security_analyzer/configuration/lint_rule_converter.dart b/lib/src/security_analyzer/configuration/lint_rule_converter.dart
index df766e6..50f4c7f 100644
--- a/lib/src/security_analyzer/configuration/lint_rule_converter.dart
+++ b/lib/src/security_analyzer/configuration/lint_rule_converter.dart
@@ -1,17 +1,21 @@
+import 'package:dart_shield/src/security_analyzer/configuration/rule_config.dart';
import 'package:dart_shield/src/security_analyzer/rules/enums/enums.dart';
import 'package:dart_shield/src/security_analyzer/rules/rule/rule.dart';
import 'package:dart_shield/src/security_analyzer/rules/rule_registry.dart';
+import 'package:glob/glob.dart';
import 'package:json_annotation/json_annotation.dart';
-class LintRuleConverter implements JsonConverter {
+class LintRuleConverter implements JsonConverter {
const LintRuleConverter();
- // Todo: Implement file exclusion
@override
- LintRule fromJson(String name) {
+ LintRule fromJson(dynamic value) {
+ final ruleConfig = RuleConfig.fromDynamic(value);
+ final excludePatterns = ruleConfig.exclude.map(Glob.new).toList();
+
final rule = RuleRegistry.createRule(
- id: RuleId.fromYamlName(name),
- excludes: [],
+ id: RuleId.fromYamlName(ruleConfig.name),
+ excludes: excludePatterns,
);
return rule;
diff --git a/lib/src/security_analyzer/configuration/rule_config.dart b/lib/src/security_analyzer/configuration/rule_config.dart
new file mode 100644
index 0000000..2c3f076
--- /dev/null
+++ b/lib/src/security_analyzer/configuration/rule_config.dart
@@ -0,0 +1,52 @@
+/// Configuration for a single rule that supports both string and
+/// object formats.
+class RuleConfig {
+ const RuleConfig({
+ required this.name,
+ this.exclude = const [],
+ });
+
+ /// Creates a RuleConfig from a dynamic value that can be either a String
+ /// or Map.
+ factory RuleConfig.fromDynamic(dynamic value) {
+ if (value is String) {
+ return RuleConfig(name: value);
+ }
+
+ if (value is Map) {
+ final entries = value.entries.toList();
+ if (entries.length != 1) {
+ throw ArgumentError('Rule config map must have exactly one entry');
+ }
+
+ final entry = entries.first;
+ final name = entry.key.toString();
+ final configValue = entry.value;
+
+ final exclude = [];
+ if (configValue is Map) {
+ final excludeList = configValue['exclude'];
+ if (excludeList is List) {
+ exclude.addAll(excludeList.map((e) => e.toString()));
+ }
+ }
+
+ return RuleConfig(name: name, exclude: exclude);
+ } else {
+ throw ArgumentError(
+ 'Rule config must be a String or Map, got ${value.runtimeType}',
+ );
+ }
+ }
+
+ /// The name of the rule (e.g., 'avoid_hardcoded_secrets').
+ final String name;
+
+ /// List of file patterns to exclude for this rule.
+ final List exclude;
+
+ Map toJson() => {
+ 'name': name,
+ 'exclude': exclude,
+ };
+}
diff --git a/lib/src/security_analyzer/configuration/shield_config.dart b/lib/src/security_analyzer/configuration/shield_config.dart
index 532a1a3..cfe3db7 100644
--- a/lib/src/security_analyzer/configuration/shield_config.dart
+++ b/lib/src/security_analyzer/configuration/shield_config.dart
@@ -12,7 +12,7 @@ import 'package:yaml/yaml.dart';
part 'shield_config.g.dart';
@JsonSerializable(
- fieldRename: FieldRename.kebab,
+ fieldRename: FieldRename.snake,
converters: [LintRuleConverter(), GlobConverter()],
createToJson: false,
)
@@ -63,7 +63,7 @@ class ShieldConfig {
.join(', ');
throw InvalidConfigurationException(
'Found experimental rule(s) in the "rules" list: $ruleNames. '
- 'Move these to "experimental-rules" list.',
+ 'Move these to "experimental_rules" list.',
);
}
@@ -74,9 +74,9 @@ class ShieldConfig {
.map((rule) => rule.id.name)
.join(', ');
throw InvalidConfigurationException(
- 'Found experimental rule(s) in "experimental-rules" list: $ruleNames, '
- 'but "enable-experimental" is set to false. '
- 'Set "enable-experimental" to true to use these rules.',
+ 'Found experimental rule(s) in "experimental_rules" list: $ruleNames, '
+ 'but "enable_experimental" is set to false. '
+ 'Set "enable_experimental" to true to use these rules.',
);
}
@@ -89,7 +89,7 @@ class ShieldConfig {
.map((rule) => rule.id.name)
.join(', ');
throw InvalidConfigurationException(
- 'Found non-experimental rule(s) in "experimental-rules" list: '
+ 'Found non-experimental rule(s) in "experimental_rules" list: '
'$ruleNames. Move these to the "rules" list.',
);
}
diff --git a/lib/src/security_analyzer/configuration/shield_config.g.dart b/lib/src/security_analyzer/configuration/shield_config.g.dart
index 7f30684..33edab2 100644
--- a/lib/src/security_analyzer/configuration/shield_config.g.dart
+++ b/lib/src/security_analyzer/configuration/shield_config.g.dart
@@ -9,15 +9,15 @@ part of 'shield_config.dart';
ShieldConfig _$ShieldConfigFromJson(Map json) => ShieldConfig(
rules:
(json['rules'] as List?)
- ?.map((e) => const LintRuleConverter().fromJson(e as String))
+ ?.map(const LintRuleConverter().fromJson)
.toList() ??
const [],
experimentalRules:
- (json['experimental-rules'] as List?)
- ?.map((e) => const LintRuleConverter().fromJson(e as String))
+ (json['experimental_rules'] as List?)
+ ?.map(const LintRuleConverter().fromJson)
.toList() ??
const [],
- enableExperimental: json['enable-experimental'] as bool? ?? false,
+ enableExperimental: json['enable_experimental'] as bool? ?? false,
exclude:
(json['exclude'] as List?)?.map((e) => e as String).toList() ??
const [],
diff --git a/lib/src/security_analyzer/rules/enums/rule_id.dart b/lib/src/security_analyzer/rules/enums/rule_id.dart
index 88c734f..6851d62 100644
--- a/lib/src/security_analyzer/rules/enums/rule_id.dart
+++ b/lib/src/security_analyzer/rules/enums/rule_id.dart
@@ -6,8 +6,8 @@ enum RuleId {
preferSecureRandom;
static RuleId fromYamlName(String name) {
- // Convert kebab-case to camelCase
- final camelCaseName = name.replaceAllMapped(RegExp(r'-(\w)'), (match) {
+ // Convert snake_case to camelCase
+ final camelCaseName = name.replaceAllMapped(RegExp(r'_(\w)'), (match) {
final matchStr = match.group(1);
return matchStr != null ? matchStr.toUpperCase() : '';
});
@@ -15,4 +15,15 @@ enum RuleId {
// Use RuleId.values.byName to get the enum value
return RuleId.values.byName(camelCaseName);
}
+
+ /// Converts the enum name to underscore format for use in suppression
+ /// comments.
+ /// Example: preferHttpsOverHttp -> prefer_https_over_http
+ String toUnderscoreCase() {
+ final name = this.name;
+ // Convert camelCase to underscore_case
+ return name.replaceAllMapped(RegExp('([a-z])([A-Z])'), (match) {
+ return '${match.group(1)}_${match.group(2)!.toLowerCase()}';
+ });
+ }
}
diff --git a/lib/src/security_analyzer/rules/models/matching_pattern.dart b/lib/src/security_analyzer/rules/models/matching_pattern.dart
index de93ee2..a6e889d 100644
--- a/lib/src/security_analyzer/rules/models/matching_pattern.dart
+++ b/lib/src/security_analyzer/rules/models/matching_pattern.dart
@@ -2,7 +2,7 @@ import 'package:json_annotation/json_annotation.dart';
part 'matching_pattern.g.dart';
-@JsonSerializable(fieldRename: FieldRename.kebab, createToJson: false)
+@JsonSerializable(fieldRename: FieldRename.snake, createToJson: false)
class MatchingPattern {
MatchingPattern({
required this.name,
diff --git a/lib/src/security_analyzer/rules/models/shield_secrets.dart b/lib/src/security_analyzer/rules/models/shield_secrets.dart
index 17d5cc8..5291bfd 100644
--- a/lib/src/security_analyzer/rules/models/shield_secrets.dart
+++ b/lib/src/security_analyzer/rules/models/shield_secrets.dart
@@ -6,7 +6,7 @@ import 'package:yaml/yaml.dart';
part 'shield_secrets.g.dart';
-@JsonSerializable(fieldRename: FieldRename.kebab, createToJson: false)
+@JsonSerializable(fieldRename: FieldRename.snake, createToJson: false)
class ShieldSecrets {
ShieldSecrets({
required this.version,
@@ -17,7 +17,7 @@ class ShieldSecrets {
factory ShieldSecrets.preset() {
// Workaround: Using assets.dart instead of native asset support
// See: https://github.com/dart-lang/sdk/issues/53562
- // TODO: Migrate to native assets when Dart SDK supports it
+ // TODO(yardex): Migrate to native assets when Dart SDK supports it
// final content = File(_defaultConfigPath).readAsStringSync();
const content = shieldSecretsSource;
final dartMap = yamlToDartMap(loadYaml(content)) as Map;
@@ -31,7 +31,7 @@ class ShieldSecrets {
// Workaround: Using assets.dart instead of native asset support
// See: https://github.com/dart-lang/sdk/issues/53562
- // TODO: Migrate to native assets when Dart SDK supports it
+ // TODO(yardex): Migrate to native assets when Dart SDK supports it
// static const _defaultConfigPath = '../rules_list/utils/shield_secrets.yaml';
static const _yamlRootKey = 'shield_patterns';
diff --git a/lib/src/security_analyzer/rules/rule/lint_issue.dart b/lib/src/security_analyzer/rules/rule/lint_issue.dart
index 17615bc..6492662 100644
--- a/lib/src/security_analyzer/rules/rule/lint_issue.dart
+++ b/lib/src/security_analyzer/rules/rule/lint_issue.dart
@@ -17,7 +17,7 @@ class LintIssue {
required SourceSpan location,
}) {
return LintIssue(
- ruleId: rule.id.name,
+ ruleId: rule.id.toUnderscoreCase(),
severity: rule.severity,
message: message,
location: location,
diff --git a/lib/src/security_analyzer/rules/rule/lint_rule.dart b/lib/src/security_analyzer/rules/rule/lint_rule.dart
index 67d5e60..c6d6ed2 100644
--- a/lib/src/security_analyzer/rules/rule/lint_rule.dart
+++ b/lib/src/security_analyzer/rules/rule/lint_rule.dart
@@ -21,6 +21,11 @@ abstract class LintRule {
final RuleStatus status;
Iterable check(ResolvedUnitResult source) {
+ // Check if file is excluded for this rule
+ if (_isFileExcluded(source.path)) {
+ return [];
+ }
+
final issues = collectErrorNodes(source);
return issues
.map(
@@ -33,6 +38,11 @@ abstract class LintRule {
.toList(growable: false);
}
+ /// Checks if the file path matches any exclusion pattern for this rule.
+ bool _isFileExcluded(String filePath) {
+ return excludes.any((glob) => glob.matches(filePath));
+ }
+
/// Collects AST nodes that violate this rule.
///
/// This method must be implemented by concrete rule implementations.
diff --git a/lib/src/security_analyzer/security_analyzer.dart b/lib/src/security_analyzer/security_analyzer.dart
index dd298b7..1a83265 100644
--- a/lib/src/security_analyzer/security_analyzer.dart
+++ b/lib/src/security_analyzer/security_analyzer.dart
@@ -4,6 +4,7 @@ import 'package:analyzer/dart/analysis/results.dart';
import 'package:dart_shield/src/security_analyzer/configuration/shield_config.dart';
import 'package:dart_shield/src/security_analyzer/extensions.dart';
import 'package:dart_shield/src/security_analyzer/report/report.dart';
+import 'package:dart_shield/src/security_analyzer/utils/suppression.dart';
import 'package:dart_shield/src/security_analyzer/workspace.dart';
import 'package:glob/glob.dart';
import 'package:path/path.dart';
@@ -64,9 +65,24 @@ class SecurityAnalyzer {
ShieldConfig config,
) {
final relativePath = relative(result.path, from: workspace.rootFolder);
- final issues = config.allRules
+ final suppression = Suppression(result.content, result.lineInfo);
+
+ // Filter rules by file-level suppression
+ final applicableRules = config.allRules.where(
+ (rule) => !suppression.isSuppressed(rule.id.toUnderscoreCase()),
+ );
+
+ // Check rules and filter issues by line-level suppression
+ final issues = applicableRules
.expand((rule) => rule.check(result))
+ .where(
+ (issue) => !suppression.isSuppressedAt(
+ issue.ruleId,
+ issue.location.start.line,
+ ),
+ )
.toList();
+
return FileReport.fromIssues(relativePath, issues);
}
diff --git a/lib/src/security_analyzer/utils/suppression.dart b/lib/src/security_analyzer/utils/suppression.dart
new file mode 100644
index 0000000..0d8f4f9
--- /dev/null
+++ b/lib/src/security_analyzer/utils/suppression.dart
@@ -0,0 +1,67 @@
+import 'package:analyzer/source/line_info.dart';
+
+/// Represents an information about rule suppression for dart_shield.
+class Suppression {
+ /// Initialize a newly created [Suppression] with the given [content]
+ /// and [lineInfo].
+ Suppression(String content, this.lineInfo) {
+ _parseIgnoreComments(content);
+ _parseIgnoreForFileComments(content);
+ }
+ static final _ignoreMatchers = RegExp(
+ '//[ ]*shield_ignore:(.*)',
+ multiLine: true,
+ );
+ static final _ignoreForFileMatcher = RegExp(
+ '//[ ]*shield_ignore_for_file:(.*)',
+ multiLine: true,
+ );
+
+ final _ignoreMap = >{};
+ final _ignoreForFileSet = {};
+
+ final LineInfo lineInfo;
+
+ /// Checks that the [id] is globally suppressed for the entire file.
+ bool isSuppressed(String id) => _ignoreForFileSet.contains(_canonicalize(id));
+
+ /// Checks that the [id] is suppressed for the [lineIndex].
+ bool isSuppressedAt(String id, int lineIndex) =>
+ isSuppressed(id) ||
+ (_ignoreMap[lineIndex]?.contains(_canonicalize(id)) ?? false);
+
+ void _parseIgnoreComments(String content) {
+ for (final match in _ignoreMatchers.allMatches(content)) {
+ final ids = match.group(1)!.split(',').map(_canonicalize);
+ final location = lineInfo.getLocation(match.start);
+ final lineNumber = location.lineNumber;
+ final offset = lineInfo.getOffsetOfLine(lineNumber - 1);
+ final beforeMatch = content.substring(
+ offset,
+ offset + location.columnNumber - 1,
+ );
+
+ // If comment sits next to code, it refers to its own line, otherwise it
+ // refers to the next line.
+ final ignoredNextLine = beforeMatch.trim().isEmpty;
+ _ignoreMap
+ .putIfAbsent(
+ ignoredNextLine ? lineNumber + 1 : lineNumber,
+ () => [],
+ )
+ .addAll(ids);
+ }
+ }
+
+ void _parseIgnoreForFileComments(String content) {
+ for (final match in _ignoreForFileMatcher.allMatches(content)) {
+ final suppressed = match.group(1)!.split(',').map(_canonicalize);
+ _ignoreForFileSet.addAll(suppressed);
+ }
+ }
+
+ /// Canonicalizes rule IDs by trimming whitespace and converting to lowercase.
+ String _canonicalize(String ruleId) {
+ return ruleId.trim().toLowerCase();
+ }
+}
diff --git a/lib/src/security_analyzer/workspace.dart b/lib/src/security_analyzer/workspace.dart
index 90e45b4..b0cbe84 100644
--- a/lib/src/security_analyzer/workspace.dart
+++ b/lib/src/security_analyzer/workspace.dart
@@ -32,5 +32,5 @@ const _defaultConfig = '''
# For more information, see [link]
shield:
rules:
- - prefer-https-over-http
+ - prefer_https_over_http
''';
diff --git a/pubspec.yaml b/pubspec.yaml
index 263a0af..990a98d 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,6 +1,6 @@
name: dart_shield
-description: A security analysis tool.
-version: 0.1.0-dev.5
+description: Open-source static analysis tool that helps secure your Dart codebase by detecting vulnerabilities before they reach production.
+version: 0.1.0-dev.6
repository: https://github.com/yardexx/dart_shield
issue_tracker: https://github.com/yardexx/dart_shield/issues
topics:
diff --git a/test/fixtures/configs/complete_config.yaml b/test/fixtures/configs/complete_config.yaml
new file mode 100644
index 0000000..9e01c65
--- /dev/null
+++ b/test/fixtures/configs/complete_config.yaml
@@ -0,0 +1,14 @@
+# Complete configuration for dart_shield
+shield:
+ rules:
+ - prefer_https_over_http
+ - avoid_hardcoded_secrets
+ experimental_rules:
+ - avoid_hardcoded_urls
+ - avoid_weak_hashing
+ - prefer_secure_random
+ enable_experimental: true
+ exclude:
+ - 'test/**'
+ - '**/*.g.dart'
+ - 'lib/generated/**'
diff --git a/test/data/config_examples.dart b/test/fixtures/configs/config_examples.dart
similarity index 53%
rename from test/data/config_examples.dart
rename to test/fixtures/configs/config_examples.dart
index 79be14f..2a027ae 100644
--- a/test/data/config_examples.dart
+++ b/test/fixtures/configs/config_examples.dart
@@ -1,7 +1,7 @@
const minimalConfig = '''
shield:
rules:
- - prefer-https-over-http
+ - prefer_https_over_http
''';
const excludePathConfig = '''
@@ -9,7 +9,7 @@ shield:
exclude:
- 'example/bar.dart'
rules:
- - prefer-https-over-http
+ - prefer_https_over_http
''';
const excludeGlobConfig = '''
@@ -17,14 +17,14 @@ shield:
exclude:
- '**.g.dart'
rules:
- - prefer-https-over-http
+ - prefer_https_over_http
''';
const onlyExperimentalConfig = '''
shield:
- enable-experimental: true
- experimental-rules:
- - avoid-hardcoded-urls
+ enable_experimental: true
+ experimental_rules:
+ - avoid_hardcoded_urls
''';
const completeConfig = '''
@@ -32,10 +32,10 @@ shield:
exclude:
- 'example/bar.dart'
rules:
- - prefer-https-over-http
- enable-experimental: true
- experimental-rules:
- - avoid-hardcoded-urls
+ - prefer_https_over_http
+ enable_experimental: true
+ experimental_rules:
+ - avoid_hardcoded_urls
''';
const invalidConfig = '''
@@ -43,8 +43,8 @@ shield:
exclude:
- 'example/bar.dart'
rules:
- - prefer-https-over-http
- enable-experimental: false
- experimental-rules:
- - avoid-hardcoded-urls
+ - prefer_https_over_http
+ enable_experimental: false
+ experimental_rules:
+ - avoid_hardcoded_urls
''';
diff --git a/test/fixtures/configs/invalid_config.yaml b/test/fixtures/configs/invalid_config.yaml
new file mode 100644
index 0000000..a07a255
--- /dev/null
+++ b/test/fixtures/configs/invalid_config.yaml
@@ -0,0 +1,10 @@
+# Invalid configuration for dart_shield
+shield:
+ rules:
+ - invalid-rule-name
+ - another-invalid-rule
+ experimental_rules:
+ - yet-another-invalid-rule
+ enable_experimental: false # This should cause validation error
+ exclude:
+ - 'test/**'
diff --git a/test/fixtures/configs/minimal_config.yaml b/test/fixtures/configs/minimal_config.yaml
new file mode 100644
index 0000000..e5fc1d2
--- /dev/null
+++ b/test/fixtures/configs/minimal_config.yaml
@@ -0,0 +1,4 @@
+# Minimal configuration for dart_shield
+shield:
+ rules:
+ - prefer_https_over_http
diff --git a/test/data/yaml_inputs.dart b/test/fixtures/configs/yaml_inputs.dart
similarity index 100%
rename from test/data/yaml_inputs.dart
rename to test/fixtures/configs/yaml_inputs.dart
diff --git a/test/fixtures/dart_files/mixed_code.dart b/test/fixtures/dart_files/mixed_code.dart
new file mode 100644
index 0000000..0739776
--- /dev/null
+++ b/test/fixtures/dart_files/mixed_code.dart
@@ -0,0 +1,61 @@
+// Test fixtures for mixed Dart code (secure and insecure)
+const String mixedCode = '''
+import 'dart:math';
+import 'dart:io';
+
+void main() {
+ // Mix of secure and insecure code
+ const apiKey = 'sk-1234567890abcdef'; // Insecure - hardcoded secret
+ final random = Random.secure(); // Secure - secure random
+
+ const httpUrl = 'http://example.com'; // Insecure - HTTP
+ const httpsUrl = 'https://secure.example.com'; // Secure - HTTPS
+
+ // Environment variable usage
+ final envKey = Platform.environment['API_KEY']; // Secure
+
+ // Weak hashing
+ final weakHash = md5.convert(utf8.encode('data')); // Insecure
+
+ // Secure hashing
+ final secureHash = sha256.convert(utf8.encode('data')); // Secure
+}
+
+class MixedApiClient {
+ static const String _baseUrl = 'https://api.example.com'; // Secure - HTTPS
+
+ final String apiKey;
+
+ MixedApiClient({required this.apiKey});
+
+ Future makeRequest() async {
+ // Insecure - HTTP URL
+ const insecureUrl = 'http://legacy.example.com/api';
+
+ // Secure - HTTPS URL
+ const secureUrl = 'https://api.example.com/v1/data';
+
+ // Insecure - hardcoded secret
+ const secretKey = 'sk-abcdef1234567890';
+
+ // Secure - environment variable
+ final envKey = Platform.environment['API_SECRET'];
+
+ // Insecure - weak random
+ final random = Random();
+
+ // Secure - secure random
+ final secureRandom = Random.secure();
+ }
+}
+
+void main() {
+ // Insecure - hardcoded API key
+ const client = MixedApiClient(apiKey: 'sk-1234567890abcdef');
+
+ // Secure - environment variable
+ final secureClient = MixedApiClient(
+ apiKey: Platform.environment['API_KEY'] ?? '',
+ );
+}
+''';
diff --git a/test/fixtures/dart_files/secure_code.dart b/test/fixtures/dart_files/secure_code.dart
new file mode 100644
index 0000000..3051e37
--- /dev/null
+++ b/test/fixtures/dart_files/secure_code.dart
@@ -0,0 +1,48 @@
+// Test fixtures for secure Dart code
+const String secureCode = '''
+import 'dart:math';
+import 'dart:io';
+
+void main() {
+ // Secure random
+ final random = Random.secure();
+
+ // HTTPS URLs
+ const url = 'https://example.com/api';
+ const endpoint = 'https://api.example.com/v1/users';
+
+ // Environment variables
+ final apiKey = Platform.environment['API_KEY'];
+ final secret = Platform.environment['SECRET_KEY'];
+
+ // Secure hashing
+ final hash = sha256.convert(utf8.encode('password'));
+ final secureHash = sha512.convert(utf8.encode('data'));
+}
+
+class SecureApiClient {
+ static const String _baseUrl = 'https://api.example.com';
+
+ final String apiKey;
+
+ SecureApiClient({required this.apiKey});
+
+ Future makeRequest() async {
+ // All URLs use HTTPS
+ const secureUrl = 'https://api.example.com/v1/data';
+
+ // All secrets from environment
+ final secretKey = Platform.environment['API_SECRET'];
+
+ // All random generation is secure
+ final random = Random.secure();
+ }
+}
+
+void main() {
+ // All API keys from environment
+ final client = SecureApiClient(
+ apiKey: Platform.environment['API_KEY'] ?? '',
+ );
+}
+''';
diff --git a/test/fixtures/dart_files/suppression_example.dart b/test/fixtures/dart_files/suppression_example.dart
new file mode 100644
index 0000000..9591e5c
--- /dev/null
+++ b/test/fixtures/dart_files/suppression_example.dart
@@ -0,0 +1,26 @@
+// shield_ignore_for_file: avoid_hardcoded_secrets
+
+// ignore_for_file: avoid_print
+
+void main() {
+ // shield_ignore: prefer_https_over_http
+ const url = 'http://example.com';
+
+ const key = 'secret'; // shield_ignore: avoid_hardcoded_secrets
+
+ // shield_ignore: avoid_weak_hashing, prefer_secure_random
+ const hash = 'md5';
+ const random = '12345';
+
+ // This should trigger violations since no suppression
+ const anotherUrl = 'http://insecure.com';
+ const anotherKey = 'another-secret';
+
+ // Use variables to avoid unused variable warnings
+ print('URL: $url');
+ print('Key: $key');
+ print('Hash: $hash');
+ print('Random: $random');
+ print('Another URL: $anotherUrl');
+ print('Another Key: $anotherKey');
+}
diff --git a/test/fixtures/dart_files/vulnerable_code.dart b/test/fixtures/dart_files/vulnerable_code.dart
new file mode 100644
index 0000000..be5a55d
--- /dev/null
+++ b/test/fixtures/dart_files/vulnerable_code.dart
@@ -0,0 +1,116 @@
+// Test fixtures for vulnerable Dart code
+const String vulnerableCode = '''
+import 'dart:math';
+
+void main() {
+ // Hardcoded secrets
+ const apiKey = 'sk-1234567890abcdef';
+ const secret = 'my-secret-key';
+ const password = 'password123';
+
+ // HTTP URLs
+ const url = 'http://example.com/api';
+ const endpoint = 'http://api.example.com/v1/users';
+
+ // Weak random
+ final random = Random();
+ final insecureRandom = Random(123);
+
+ // Hardcoded URLs
+ const apiUrl = 'https://api.example.com/v1/users';
+ const webhookUrl = 'https://webhook.example.com/callback';
+
+ // MD5 usage
+ final hash = md5.convert(utf8.encode('password'));
+
+ // SHA1 usage
+ final sha1Hash = sha1.convert(utf8.encode('data'));
+}
+''';
+
+const String secureCode = '''
+import 'dart:math';
+import 'dart:io';
+
+void main() {
+ // Secure random
+ final random = Random.secure();
+
+ // HTTPS URLs
+ const url = 'https://example.com/api';
+ const endpoint = 'https://api.example.com/v1/users';
+
+ // Environment variables
+ final apiKey = Platform.environment['API_KEY'];
+ final secret = Platform.environment['SECRET_KEY'];
+
+ // Secure hashing
+ final hash = sha256.convert(utf8.encode('password'));
+ final secureHash = sha512.convert(utf8.encode('data'));
+}
+''';
+
+const String mixedCode = '''
+import 'dart:math';
+
+void main() {
+ // Mix of secure and insecure code
+ const apiKey = 'sk-1234567890abcdef'; // Insecure - hardcoded secret
+ final random = Random.secure(); // Secure - secure random
+
+ const httpUrl = 'http://example.com'; // Insecure - HTTP
+ const httpsUrl = 'https://secure.example.com'; // Secure - HTTPS
+
+ // Environment variable usage
+ final envKey = Platform.environment['API_KEY']; // Secure
+
+ // Weak hashing
+ final weakHash = md5.convert(utf8.encode('data')); // Insecure
+
+ // Secure hashing
+ final secureHash = sha256.convert(utf8.encode('data')); // Secure
+}
+''';
+
+const String complexCode = '''
+import 'dart:math';
+import 'dart:io';
+
+class ApiClient {
+ static const String _baseUrl = 'https://api.example.com'; // Secure - HTTPS
+
+ final String apiKey;
+
+ ApiClient({required this.apiKey});
+
+ Future makeRequest() async {
+ // Insecure - HTTP URL
+ const insecureUrl = 'http://legacy.example.com/api';
+
+ // Secure - HTTPS URL
+ const secureUrl = 'https://api.example.com/v1/data';
+
+ // Insecure - hardcoded secret
+ const secretKey = 'sk-abcdef1234567890';
+
+ // Secure - environment variable
+ final envKey = Platform.environment['API_SECRET'];
+
+ // Insecure - weak random
+ final random = Random();
+
+ // Secure - secure random
+ final secureRandom = Random.secure();
+ }
+}
+
+void main() {
+ // Insecure - hardcoded API key
+ const client = ApiClient(apiKey: 'sk-1234567890abcdef');
+
+ // Secure - environment variable
+ final secureClient = ApiClient(
+ apiKey: Platform.environment['API_KEY'] ?? '',
+ );
+}
+''';
diff --git a/test/helpers/test_analyzer.dart b/test/helpers/test_analyzer.dart
new file mode 100644
index 0000000..bd0feb3
--- /dev/null
+++ b/test/helpers/test_analyzer.dart
@@ -0,0 +1,230 @@
+import 'dart:io';
+
+import 'package:analyzer/dart/analysis/analysis_context_collection.dart';
+import 'package:analyzer/dart/analysis/results.dart';
+import 'package:path/path.dart' as path;
+
+/// Helper class for creating test analyzers and managing test files
+class TestAnalyzer {
+ /// Creates a temporary directory for test files
+ static Directory createTempDir() {
+ return Directory.systemTemp.createTempSync('dart_shield_test_');
+ }
+
+ /// Creates a temporary Dart file with the given content
+ static File createTempDartFile(
+ Directory tempDir,
+ String filename,
+ String content,
+ ) {
+ final file = File(path.join(tempDir.path, filename))
+ ..writeAsStringSync(content);
+ return file;
+ }
+
+ /// Analyzes Dart code and returns a ResolvedUnitResult
+ static Future analyzeCode(
+ String code, {
+ String filename = 'test.dart',
+ }) async {
+ final tempDir = createTempDir();
+ try {
+ final tempFile = createTempDartFile(tempDir, filename, code);
+
+ // Create analysis context
+ final collection = AnalysisContextCollection(
+ includedPaths: [tempDir.path],
+ );
+ final context = collection.contexts.first;
+
+ // Get resolved unit
+ final result = await context.currentSession.getResolvedUnit(
+ tempFile.path,
+ );
+
+ if (result is ResolvedUnitResult) {
+ return result;
+ } else {
+ throw Exception('Failed to resolve unit: $result');
+ }
+ } finally {
+ // Clean up temporary directory
+ tempDir.deleteSync(recursive: true);
+ }
+ }
+
+ /// Analyzes multiple Dart files in a temporary directory
+ static Future> analyzeMultipleFiles(
+ Map files,
+ ) async {
+ final tempDir = createTempDir();
+ try {
+ // Create all files
+ for (final entry in files.entries) {
+ createTempDartFile(tempDir, entry.key, entry.value);
+ }
+
+ // Create analysis context
+ final collection = AnalysisContextCollection(
+ includedPaths: [tempDir.path],
+ );
+ final context = collection.contexts.first;
+
+ // Analyze all files
+ final results = [];
+ for (final entry in files.entries) {
+ final filePath = path.join(tempDir.path, entry.key);
+ final result = await context.currentSession.getResolvedUnit(filePath);
+
+ if (result is ResolvedUnitResult) {
+ results.add(result);
+ }
+ }
+
+ return results;
+ } finally {
+ // Clean up temporary directory
+ tempDir.deleteSync(recursive: true);
+ }
+ }
+
+ /// Creates a test workspace with sample files
+ static Directory createTestWorkspace({
+ Map dartFiles = const {},
+ String? configContent,
+ }) {
+ final tempDir = createTempDir();
+
+ // Create Dart files
+ for (final entry in dartFiles.entries) {
+ createTempDartFile(tempDir, entry.key, entry.value);
+ }
+
+ // Create config file if provided
+ if (configContent != null) {
+ File(
+ path.join(tempDir.path, 'shield_options.yaml'),
+ ).writeAsStringSync(configContent);
+ }
+
+ return tempDir;
+ }
+
+ /// Cleans up a temporary directory
+ static void cleanupTempDir(Directory tempDir) {
+ if (tempDir.existsSync()) {
+ tempDir.deleteSync(recursive: true);
+ }
+ }
+}
+
+/// Mock workspace for testing
+class MockWorkspace {
+ MockWorkspace({
+ required this.rootFolder,
+ required this.analyzedPaths,
+ this.files = const {},
+ this.configContent,
+ });
+
+ final String rootFolder;
+ final List analyzedPaths;
+ final Map files;
+ final String? configContent;
+
+ String get configPath => path.join(rootFolder, 'shield_options.yaml');
+
+ List get normalizedFolders => analyzedPaths
+ .map(
+ (analyzedPath) => analyzedPath.startsWith('/')
+ ? analyzedPath
+ : path.join(rootFolder, analyzedPath),
+ )
+ .toList();
+
+ bool get configExists => configContent != null;
+
+ void createDefaultConfig() {
+ if (configContent != null) {
+ File(configPath).writeAsStringSync(configContent!);
+ }
+ }
+}
+
+/// Test data builder for creating test scenarios
+class TestDataBuilder {
+ static const String vulnerableCode = '''
+import 'dart:math';
+
+void main() {
+ // Hardcoded secrets
+ const apiKey = 'sk-1234567890abcdef';
+ const secret = 'my-secret-key';
+
+ // HTTP URLs
+ const url = 'http://example.com/api';
+
+ // Weak random
+ final random = Random();
+
+ // Hardcoded URLs
+ const endpoint = 'https://api.example.com/v1/users';
+}
+''';
+
+ static const String secureCode = '''
+import 'dart:math';
+
+void main() {
+ // Secure random
+ final random = Random.secure();
+
+ // HTTPS URLs
+ const url = 'https://example.com/api';
+
+ // Environment variables
+ final apiKey = Platform.environment['API_KEY'];
+}
+''';
+
+ static const String mixedCode = '''
+import 'dart:math';
+
+void main() {
+ // Mix of secure and insecure code
+ const apiKey = 'sk-1234567890abcdef'; // Insecure
+ final random = Random.secure(); // Secure
+
+ const httpUrl = 'http://example.com'; // Insecure
+ const httpsUrl = 'https://secure.example.com'; // Secure
+}
+''';
+
+ static const String minimalConfig = '''
+shield:
+ rules:
+ - prefer-https-over-http
+''';
+
+ static const String completeConfig = '''
+shield:
+ rules:
+ - prefer_https_over_http
+ - avoid_hardcoded_secrets
+ experimental_rules:
+ - avoid_hardcoded_urls
+ - avoid_weak_hashing
+ enable_experimental: true
+ exclude:
+ - 'test/**'
+ - '**/*.g.dart'
+''';
+
+ static const String invalidConfig = '''
+shield:
+ rules:
+ - invalid-rule
+ experimental_rules:
+ - another-invalid-rule
+''';
+}
diff --git a/test/src/security_analyzer/configuration/lint_rule_converter_test.dart b/test/src/security_analyzer/configuration/lint_rule_converter_test.dart
deleted file mode 100644
index b741f07..0000000
--- a/test/src/security_analyzer/configuration/lint_rule_converter_test.dart
+++ /dev/null
@@ -1,30 +0,0 @@
-import 'package:dart_shield/src/security_analyzer/configuration/lint_rule_converter.dart';
-import 'package:dart_shield/src/security_analyzer/rules/enums/enums.dart';
-import 'package:dart_shield/src/security_analyzer/rules/rules.dart';
-import 'package:test/test.dart';
-
-void main() {
- const converter = LintRuleConverter();
-
- group('LintRuleConverter', () {
- test('fromJson should return a LintRule object for valid ruleId', () {
- final rule = converter.fromJson(RuleId.avoidHardcodedUrls.name);
- expect(rule, isA());
- expect(rule.id, equals(RuleId.avoidHardcodedUrls));
- });
-
- test('fromJson should throw an exception for invalid ruleId', () {
- expect(() => converter.fromJson('Invalid_RuleId'), throwsArgumentError);
- });
-
- test('toJson should return a string for valid LintRule', () {
- final rule = RuleRegistry.createRule(
- id: RuleId.avoidHardcodedUrls,
- excludes: [],
- );
- final ruleId = converter.toJson(rule);
- expect(ruleId, isA());
- expect(ruleId, equals(RuleId.avoidHardcodedUrls.name));
- });
- });
-}
diff --git a/test/src/security_analyzer/configuration/glob_converter_test.dart b/test/unit/security_analyzer/configuration/glob_converter_test.dart
similarity index 100%
rename from test/src/security_analyzer/configuration/glob_converter_test.dart
rename to test/unit/security_analyzer/configuration/glob_converter_test.dart
diff --git a/test/unit/security_analyzer/configuration/lint_rule_converter_test.dart b/test/unit/security_analyzer/configuration/lint_rule_converter_test.dart
new file mode 100644
index 0000000..edb5976
--- /dev/null
+++ b/test/unit/security_analyzer/configuration/lint_rule_converter_test.dart
@@ -0,0 +1,101 @@
+import 'package:dart_shield/src/security_analyzer/configuration/lint_rule_converter.dart';
+import 'package:dart_shield/src/security_analyzer/rules/enums/enums.dart';
+import 'package:dart_shield/src/security_analyzer/rules/rules.dart';
+import 'package:glob/glob.dart';
+import 'package:test/test.dart';
+
+void main() {
+ const converter = LintRuleConverter();
+
+ group('LintRuleConverter', () {
+ group('string format', () {
+ test('fromJson should return a LintRule object for valid ruleId', () {
+ final rule = converter.fromJson('avoid_hardcoded_urls');
+ expect(rule, isA());
+ expect(rule.id, equals(RuleId.avoidHardcodedUrls));
+ expect(rule.excludes, isEmpty);
+ });
+
+ test('fromJson should throw an exception for invalid ruleId', () {
+ expect(
+ () => converter.fromJson('invalid_rule_id'),
+ throwsArgumentError,
+ );
+ });
+ });
+
+ group('object format', () {
+ test('fromJson should parse object format with exclude patterns', () {
+ final rule = converter.fromJson({
+ 'avoid_hardcoded_secrets': {
+ 'exclude': ['test/**', 'lib/config.dart'],
+ },
+ });
+ expect(rule, isA());
+ expect(rule.id, equals(RuleId.avoidHardcodedSecrets));
+ expect(rule.excludes, hasLength(2));
+ expect(rule.excludes.any((glob) => glob.pattern == 'test/**'), isTrue);
+ expect(
+ rule.excludes.any((glob) => glob.pattern == 'lib/config.dart'),
+ isTrue,
+ );
+ });
+
+ test('fromJson should handle object format with empty exclude', () {
+ final rule = converter.fromJson({
+ 'prefer_https_over_http': {},
+ });
+ expect(rule, isA());
+ expect(rule.id, equals(RuleId.preferHttpsOverHttp));
+ expect(rule.excludes, isEmpty);
+ });
+
+ test('fromJson should handle object format with missing exclude', () {
+ final rule = converter.fromJson({
+ 'prefer_https_over_http': {'other-config': 'value'},
+ });
+ expect(rule, isA());
+ expect(rule.id, equals(RuleId.preferHttpsOverHttp));
+ expect(rule.excludes, isEmpty);
+ });
+
+ test('fromJson should throw for object format with multiple entries', () {
+ expect(
+ () => converter.fromJson({
+ 'rule1': {},
+ 'rule2': {},
+ }),
+ throwsArgumentError,
+ );
+ });
+ });
+
+ group('mixed format support', () {
+ test('should handle both string and object formats', () {
+ // Test string format
+ final stringRule = converter.fromJson('avoid_hardcoded_urls');
+ expect(stringRule.id, equals(RuleId.avoidHardcodedUrls));
+ expect(stringRule.excludes, isEmpty);
+
+ // Test object format
+ final objectRule = converter.fromJson({
+ 'avoid_hardcoded_secrets': {
+ 'exclude': ['test/**'],
+ },
+ });
+ expect(objectRule.id, equals(RuleId.avoidHardcodedSecrets));
+ expect(objectRule.excludes, hasLength(1));
+ });
+ });
+
+ test('toJson should return a string for valid LintRule', () {
+ final rule = RuleRegistry.createRule(
+ id: RuleId.avoidHardcodedUrls,
+ excludes: [Glob('test/**')],
+ );
+ final ruleId = converter.toJson(rule);
+ expect(ruleId, isA());
+ expect(ruleId, equals(RuleId.avoidHardcodedUrls.name));
+ });
+ });
+}
diff --git a/test/src/security_analyzer/configuration/shield_config_test.dart b/test/unit/security_analyzer/configuration/shield_config_test.dart
similarity index 97%
rename from test/src/security_analyzer/configuration/shield_config_test.dart
rename to test/unit/security_analyzer/configuration/shield_config_test.dart
index e0b8893..6544e0d 100644
--- a/test/src/security_analyzer/configuration/shield_config_test.dart
+++ b/test/unit/security_analyzer/configuration/shield_config_test.dart
@@ -5,7 +5,7 @@ import 'package:dart_shield/src/utils/utils.dart';
import 'package:test/test.dart';
import 'package:yaml/yaml.dart';
-import '../../../data/config_examples.dart';
+import '../../../fixtures/configs/config_examples.dart';
void main() {
group('$ShieldConfig - valid configuration', () {
diff --git a/test/unit/security_analyzer/rules/enums/rule_id_test.dart b/test/unit/security_analyzer/rules/enums/rule_id_test.dart
new file mode 100644
index 0000000..901539b
--- /dev/null
+++ b/test/unit/security_analyzer/rules/enums/rule_id_test.dart
@@ -0,0 +1,118 @@
+import 'package:dart_shield/src/security_analyzer/rules/enums/enums.dart';
+import 'package:test/test.dart';
+
+void main() {
+ group('RuleId', () {
+ group('fromYamlName', () {
+ test('converts snake_case to camelCase correctly', () {
+ expect(
+ RuleId.fromYamlName('prefer_https_over_http'),
+ equals(RuleId.preferHttpsOverHttp),
+ );
+ expect(
+ RuleId.fromYamlName('avoid_hardcoded_urls'),
+ equals(RuleId.avoidHardcodedUrls),
+ );
+ expect(
+ RuleId.fromYamlName('avoid_hardcoded_secrets'),
+ equals(RuleId.avoidHardcodedSecrets),
+ );
+ expect(
+ RuleId.fromYamlName('avoid_weak_hashing'),
+ equals(RuleId.avoidWeakHashing),
+ );
+ expect(
+ RuleId.fromYamlName('prefer_secure_random'),
+ equals(RuleId.preferSecureRandom),
+ );
+ });
+
+ test('handles single word rules', () {
+ // Test edge case for rules without underscores - should throw for
+ // non-existent rules
+ expect(
+ () => RuleId.fromYamlName('test_rule'),
+ throwsA(isA()),
+ );
+ });
+
+ test('throws for invalid rule names', () {
+ expect(
+ () => RuleId.fromYamlName('invalid_rule'),
+ throwsA(isA()),
+ );
+ expect(() => RuleId.fromYamlName(''), throwsA(isA()));
+ expect(
+ () => RuleId.fromYamlName('unknown_rule_name'),
+ throwsA(isA()),
+ );
+ });
+
+ test('handles case sensitivity', () {
+ expect(
+ () => RuleId.fromYamlName('PREFER_HTTPS_OVER_HTTP'),
+ throwsA(isA()),
+ );
+ });
+ });
+
+ group('toUnderscoreCase', () {
+ test('converts camelCase to underscore_case correctly', () {
+ expect(
+ RuleId.preferHttpsOverHttp.toUnderscoreCase(),
+ equals('prefer_https_over_http'),
+ );
+ expect(
+ RuleId.avoidHardcodedUrls.toUnderscoreCase(),
+ equals('avoid_hardcoded_urls'),
+ );
+ expect(
+ RuleId.avoidHardcodedSecrets.toUnderscoreCase(),
+ equals('avoid_hardcoded_secrets'),
+ );
+ expect(
+ RuleId.avoidWeakHashing.toUnderscoreCase(),
+ equals('avoid_weak_hashing'),
+ );
+ expect(
+ RuleId.preferSecureRandom.toUnderscoreCase(),
+ equals('prefer_secure_random'),
+ );
+ });
+
+ test('handles single word rules', () {
+ // Test edge case for rules without camelCase
+ expect(
+ RuleId.preferSecureRandom.toUnderscoreCase(),
+ equals('prefer_secure_random'),
+ );
+ });
+ });
+
+ group('enum values', () {
+ test('has all expected rule IDs', () {
+ expect(RuleId.values.length, equals(5));
+ expect(RuleId.values, contains(RuleId.preferHttpsOverHttp));
+ expect(RuleId.values, contains(RuleId.avoidHardcodedUrls));
+ expect(RuleId.values, contains(RuleId.avoidHardcodedSecrets));
+ expect(RuleId.values, contains(RuleId.avoidWeakHashing));
+ expect(RuleId.values, contains(RuleId.preferSecureRandom));
+ });
+
+ test('enum values are unique', () {
+ final values = RuleId.values.map((e) => e.name).toSet();
+ expect(values.length, equals(RuleId.values.length));
+ });
+ });
+
+ group('round-trip conversion', () {
+ test('fromYamlName and toUnderscoreCase work together', () {
+ for (final ruleId in RuleId.values) {
+ final underscoreCase = ruleId.toUnderscoreCase();
+ final convertedBack = RuleId.fromYamlName(underscoreCase);
+ expect(convertedBack, equals(ruleId));
+ }
+ });
+ });
+ });
+}
diff --git a/test/unit/security_analyzer/rules/enums/rule_status_test.dart b/test/unit/security_analyzer/rules/enums/rule_status_test.dart
new file mode 100644
index 0000000..1d8f42c
--- /dev/null
+++ b/test/unit/security_analyzer/rules/enums/rule_status_test.dart
@@ -0,0 +1,56 @@
+import 'package:dart_shield/src/security_analyzer/rules/enums/enums.dart';
+import 'package:test/test.dart';
+
+void main() {
+ group('RuleStatus', () {
+ group('enum values', () {
+ test('has experimental status', () {
+ expect(RuleStatus.experimental.name, equals('experimental'));
+ });
+
+ test('has stable status', () {
+ expect(RuleStatus.stable.name, equals('stable'));
+ });
+
+ test('has deprecated status', () {
+ expect(RuleStatus.deprecated.name, equals('deprecated'));
+ });
+ });
+
+ group('enum properties', () {
+ test('has all expected status values', () {
+ expect(RuleStatus.values.length, equals(3));
+ expect(RuleStatus.values, contains(RuleStatus.experimental));
+ expect(RuleStatus.values, contains(RuleStatus.stable));
+ expect(RuleStatus.values, contains(RuleStatus.deprecated));
+ });
+
+ test('enum values are unique', () {
+ final values = RuleStatus.values.map((e) => e.name).toSet();
+ expect(values.length, equals(RuleStatus.values.length));
+ });
+ });
+
+ group('status lifecycle', () {
+ test('experimental is initial status', () {
+ expect(RuleStatus.experimental.name, equals('experimental'));
+ });
+
+ test('stable is production status', () {
+ expect(RuleStatus.stable.name, equals('stable'));
+ });
+
+ test('deprecated is end-of-life status', () {
+ expect(RuleStatus.deprecated.name, equals('deprecated'));
+ });
+ });
+
+ group('status ordering', () {
+ test('status values are ordered logically', () {
+ expect(RuleStatus.values.indexOf(RuleStatus.experimental), equals(0));
+ expect(RuleStatus.values.indexOf(RuleStatus.stable), equals(1));
+ expect(RuleStatus.values.indexOf(RuleStatus.deprecated), equals(2));
+ });
+ });
+ });
+}
diff --git a/test/unit/security_analyzer/rules/enums/severity_test.dart b/test/unit/security_analyzer/rules/enums/severity_test.dart
new file mode 100644
index 0000000..2decb98
--- /dev/null
+++ b/test/unit/security_analyzer/rules/enums/severity_test.dart
@@ -0,0 +1,84 @@
+import 'package:analyzer_plugin/protocol/protocol_common.dart';
+import 'package:dart_shield/src/security_analyzer/rules/enums/enums.dart';
+import 'package:test/test.dart';
+
+void main() {
+ group('Severity', () {
+ group('enum values', () {
+ test('has correct critical severity', () {
+ expect(Severity.critical.value, equals('critical'));
+ expect(
+ Severity.critical.analysisSeverity,
+ equals(AnalysisErrorSeverity.ERROR),
+ );
+ });
+
+ test('has correct warning severity', () {
+ expect(Severity.warning.value, equals('warning'));
+ expect(
+ Severity.warning.analysisSeverity,
+ equals(AnalysisErrorSeverity.WARNING),
+ );
+ });
+
+ test('has correct info severity', () {
+ expect(Severity.info.value, equals('info'));
+ expect(
+ Severity.info.analysisSeverity,
+ equals(AnalysisErrorSeverity.INFO),
+ );
+ });
+ });
+
+ group('enum properties', () {
+ test('has all expected severity levels', () {
+ expect(Severity.values.length, equals(3));
+ expect(Severity.values, contains(Severity.critical));
+ expect(Severity.values, contains(Severity.warning));
+ expect(Severity.values, contains(Severity.info));
+ });
+
+ test('severity levels are ordered correctly', () {
+ expect(Severity.values.indexOf(Severity.critical), equals(0));
+ expect(Severity.values.indexOf(Severity.warning), equals(1));
+ expect(Severity.values.indexOf(Severity.info), equals(2));
+ });
+
+ test('enum values are unique', () {
+ final values = Severity.values.map((e) => e.name).toSet();
+ expect(values.length, equals(Severity.values.length));
+ });
+ });
+
+ group('severity comparison', () {
+ test('critical is more severe than warning', () {
+ expect(
+ Severity.critical.analysisSeverity.index,
+ greaterThan(Severity.warning.analysisSeverity.index),
+ );
+ });
+
+ test('warning is more severe than info', () {
+ expect(
+ Severity.warning.analysisSeverity.index,
+ greaterThan(Severity.info.analysisSeverity.index),
+ );
+ });
+
+ test('critical is more severe than info', () {
+ expect(
+ Severity.critical.analysisSeverity.index,
+ greaterThan(Severity.info.analysisSeverity.index),
+ );
+ });
+ });
+
+ group('string representation', () {
+ test('value property matches name', () {
+ for (final severity in Severity.values) {
+ expect(severity.value, equals(severity.name));
+ }
+ });
+ });
+ });
+}
diff --git a/test/unit/security_analyzer/rules/models/lint_issue_test.dart b/test/unit/security_analyzer/rules/models/lint_issue_test.dart
new file mode 100644
index 0000000..f088042
--- /dev/null
+++ b/test/unit/security_analyzer/rules/models/lint_issue_test.dart
@@ -0,0 +1,226 @@
+import 'package:analyzer/dart/analysis/results.dart';
+import 'package:analyzer/dart/ast/syntactic_entity.dart';
+import 'package:dart_shield/src/security_analyzer/rules/enums/enums.dart';
+import 'package:dart_shield/src/security_analyzer/rules/rule/rule.dart';
+import 'package:source_span/source_span.dart';
+import 'package:test/test.dart';
+
+void main() {
+ group('LintIssue', () {
+ group('constructor', () {
+ test('creates LintIssue with all required fields', () {
+ final location = SourceSpan(
+ SourceLocation(0, sourceUrl: Uri.parse('file://test.dart')),
+ SourceLocation(12, sourceUrl: Uri.parse('file://test.dart')),
+ 'test content',
+ );
+
+ final issue = LintIssue(
+ ruleId: 'test_rule',
+ severity: Severity.warning,
+ message: 'Test message',
+ location: location,
+ );
+
+ expect(issue.ruleId, equals('test_rule'));
+ expect(issue.severity, equals(Severity.warning));
+ expect(issue.message, equals('Test message'));
+ expect(issue.location, equals(location));
+ });
+
+ test('handles different severity levels', () {
+ final location = SourceSpan(
+ SourceLocation(0, sourceUrl: Uri.parse('file://test.dart')),
+ SourceLocation(12, sourceUrl: Uri.parse('file://test.dart')),
+ 'test content',
+ );
+
+ final criticalIssue = LintIssue(
+ ruleId: 'critical_rule',
+ severity: Severity.critical,
+ message: 'Critical message',
+ location: location,
+ );
+
+ final warningIssue = LintIssue(
+ ruleId: 'warning_rule',
+ severity: Severity.warning,
+ message: 'Warning message',
+ location: location,
+ );
+
+ final infoIssue = LintIssue(
+ ruleId: 'info_rule',
+ severity: Severity.info,
+ message: 'Info message',
+ location: location,
+ );
+
+ expect(criticalIssue.severity, equals(Severity.critical));
+ expect(warningIssue.severity, equals(Severity.warning));
+ expect(infoIssue.severity, equals(Severity.info));
+ });
+ });
+
+ group('withRule factory', () {
+ test('creates LintIssue from rule with correct properties', () {
+ final location = SourceSpan(
+ SourceLocation(0, sourceUrl: Uri.parse('file://test.dart')),
+ SourceLocation(12, sourceUrl: Uri.parse('file://test.dart')),
+ 'test content',
+ );
+
+ // Create a mock rule for testing
+ final rule = _MockLintRule(
+ id: RuleId.avoidHardcodedSecrets,
+ severity: Severity.critical,
+ message: 'Avoid hardcoding secrets',
+ );
+
+ final issue = LintIssue.withRule(
+ rule: rule,
+ message: 'Custom message',
+ location: location,
+ );
+
+ expect(issue.ruleId, equals('avoid_hardcoded_secrets'));
+ expect(issue.severity, equals(Severity.critical));
+ expect(issue.message, equals('Custom message'));
+ expect(issue.location, equals(location));
+ });
+
+ test('uses rule properties correctly', () {
+ final location = SourceSpan(
+ SourceLocation(0, sourceUrl: Uri.parse('file://test.dart')),
+ SourceLocation(12, sourceUrl: Uri.parse('file://test.dart')),
+ 'test content',
+ );
+
+ final rule = _MockLintRule(
+ id: RuleId.preferHttpsOverHttp,
+ severity: Severity.info,
+ message: 'Prefer HTTPS over HTTP',
+ );
+
+ final issue = LintIssue.withRule(
+ rule: rule,
+ message: 'Use HTTPS instead',
+ location: location,
+ );
+
+ expect(issue.ruleId, equals('prefer_https_over_http'));
+ expect(issue.severity, equals(Severity.info));
+ expect(issue.message, equals('Use HTTPS instead'));
+ });
+ });
+
+ group('toJson', () {
+ test('converts LintIssue to JSON correctly', () {
+ final location = SourceSpan(
+ SourceLocation(
+ 5,
+ sourceUrl: Uri.parse('file://test.dart'),
+ line: 1,
+ column: 5,
+ ),
+ SourceLocation(
+ 17,
+ sourceUrl: Uri.parse('file://test.dart'),
+ line: 1,
+ column: 17,
+ ),
+ 'test content',
+ );
+
+ final issue = LintIssue(
+ ruleId: 'test_rule',
+ severity: Severity.warning,
+ message: 'Test message',
+ location: location,
+ );
+
+ final json = issue.toJson();
+
+ expect(json['ruleId'], equals('test_rule'));
+ expect(json['severity'], equals('warning'));
+ expect(json['message'], equals('Test message'));
+ expect(json['location'], isA