From 2fdc4525da451a38b67071535168c472c42ed7f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Dec 2025 14:16:46 +0000 Subject: [PATCH 1/2] feat: add dead code static analysis pass with fixtures Add a new static analysis pass that detects: - Unused variables (assigned but never read) - Unused function/method parameters - Unreachable code (after return, throw, break, continue) Includes PHP fixture files for testing and HSpec test suite. --- sanctify-php.cabal | 1 + src/Sanctify/Analysis/DeadCode.hs | 364 ++++++++++++++++++++++++++++ test/Main.hs | 159 ++++++++++++ test/fixtures/clean_code.php | 62 +++++ test/fixtures/unreachable_code.php | 50 ++++ test/fixtures/unused_parameters.php | 42 ++++ test/fixtures/unused_variables.php | 30 +++ 7 files changed, 708 insertions(+) create mode 100644 src/Sanctify/Analysis/DeadCode.hs create mode 100644 test/Main.hs create mode 100644 test/fixtures/clean_code.php create mode 100644 test/fixtures/unreachable_code.php create mode 100644 test/fixtures/unused_parameters.php create mode 100644 test/fixtures/unused_variables.php diff --git a/sanctify-php.cabal b/sanctify-php.cabal index 6d3f373..517cd67 100644 --- a/sanctify-php.cabal +++ b/sanctify-php.cabal @@ -32,6 +32,7 @@ library Sanctify.Analysis.Security Sanctify.Analysis.Types Sanctify.Analysis.Taint + Sanctify.Analysis.DeadCode Sanctify.Transform.StrictTypes Sanctify.Transform.TypeHints Sanctify.Transform.Sanitize diff --git a/src/Sanctify/Analysis/DeadCode.hs b/src/Sanctify/Analysis/DeadCode.hs new file mode 100644 index 0000000..daa4499 --- /dev/null +++ b/src/Sanctify/Analysis/DeadCode.hs @@ -0,0 +1,364 @@ +-- | Dead code analysis for PHP +-- Detects unused variables and unreachable code +-- SPDX-License-Identifier: AGPL-3.0-or-later +module Sanctify.Analysis.DeadCode + ( -- * Main analysis + analyzeDeadCode + , DeadCodeIssue(..) + , DeadCodeType(..) + + -- * Specific checks + , findUnusedVariables + , findUnreachableCode + ) where + +import Data.Text (Text) +import qualified Data.Text as T +import Data.Set (Set) +import qualified Data.Set as Set +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as Map +import Control.Monad.State.Strict +import GHC.Generics (Generic) +import Data.Aeson (ToJSON) + +import Sanctify.AST + +-- | Types of dead code issues +data DeadCodeType + = UnusedVariable -- ^ Variable declared but never used + | UnreachableCode -- ^ Code after return/throw/exit + | UnusedParameter -- ^ Function parameter never used + | UnusedImport -- ^ Use statement never referenced + deriving stock (Eq, Show, Generic) + deriving anyclass (ToJSON) + +-- | A detected dead code issue +data DeadCodeIssue = DeadCodeIssue + { dcType :: DeadCodeType + , dcLocation :: SourcePos + , dcDescription :: Text + , dcIdentifier :: Text -- The name of the unused variable/etc. + } + deriving stock (Eq, Show, Generic) + deriving anyclass (ToJSON) + +-- | Analysis state for tracking variable usage +data AnalysisState = AnalysisState + { asDeclared :: Map Text SourcePos -- ^ Variables that have been declared/assigned + , asUsed :: Set Text -- ^ Variables that have been read + , asIssues :: [DeadCodeIssue] -- ^ Accumulated issues + } + deriving stock (Eq, Show) + +type AnalysisM = State AnalysisState + +-- | Initial analysis state +initialState :: AnalysisState +initialState = AnalysisState Map.empty Set.empty [] + +-- | Analyze a PHP file for dead code issues +analyzeDeadCode :: PhpFile -> [DeadCodeIssue] +analyzeDeadCode file = + let finalState = execState (analyzeStatements (phpStatements file)) initialState + unusedVars = findUnusedFromState finalState + in unusedVars ++ asIssues finalState + +-- | Find unused variables from the final state +findUnusedFromState :: AnalysisState -> [DeadCodeIssue] +findUnusedFromState state = + let declared = asDeclared state + used = asUsed state + unused = Map.filterWithKey (\k _ -> not (Set.member k used)) declared + in map mkUnusedIssue (Map.toList unused) + where + mkUnusedIssue (name, pos) = DeadCodeIssue + { dcType = UnusedVariable + , dcLocation = pos + , dcDescription = "Variable '$" <> name <> "' is assigned but never used" + , dcIdentifier = name + } + +-- | Analyze a list of statements, detecting unreachable code +analyzeStatements :: [Located Statement] -> AnalysisM () +analyzeStatements [] = pure () +analyzeStatements (stmt:rest) = do + analyzeStatement stmt + -- Check if current statement is a terminator + when (isTerminator (locNode stmt)) $ do + -- Mark remaining statements as unreachable + forM_ rest $ \(Located pos s) -> + unless (isNoop s) $ + addIssue DeadCodeIssue + { dcType = UnreachableCode + , dcLocation = pos + , dcDescription = "Unreachable code after " <> terminatorName (locNode stmt) + , dcIdentifier = "" + } + -- Continue analyzing rest only if not a terminator + unless (isTerminator (locNode stmt)) $ + analyzeStatements rest + +-- | Check if a statement is a control flow terminator +isTerminator :: Statement -> Bool +isTerminator = \case + StmtReturn _ -> True + StmtThrow _ -> True + StmtBreak _ -> True + StmtContinue _ -> True + _ -> False + +-- | Get the name of the terminator for error messages +terminatorName :: Statement -> Text +terminatorName = \case + StmtReturn _ -> "return statement" + StmtThrow _ -> "throw statement" + StmtBreak _ -> "break statement" + StmtContinue _ -> "continue statement" + _ -> "terminating statement" + +-- | Check if a statement is a no-op (empty statement) +isNoop :: Statement -> Bool +isNoop StmtNoop = True +isNoop _ = False + +-- | Add an issue to the state +addIssue :: DeadCodeIssue -> AnalysisM () +addIssue issue = modify' $ \s -> s { asIssues = issue : asIssues s } + +-- | Record that a variable was declared/assigned +declareVar :: Text -> SourcePos -> AnalysisM () +declareVar name pos = modify' $ \s -> + s { asDeclared = Map.insert name pos (asDeclared s) } + +-- | Record that a variable was used/read +useVar :: Text -> AnalysisM () +useVar name = modify' $ \s -> + s { asUsed = Set.insert name (asUsed s) } + +-- | Analyze a single statement +analyzeStatement :: Located Statement -> AnalysisM () +analyzeStatement (Located pos stmt) = case stmt of + StmtExpr expr -> analyzeExpr expr + + StmtIf cond thenStmts elseStmts -> do + analyzeExpr cond + -- Analyze branches in isolated scopes for unreachable code + analyzeStatements thenStmts + maybe (pure ()) analyzeStatements elseStmts + + StmtWhile cond body -> do + analyzeExpr cond + analyzeStatements body + + StmtFor init cond update body -> do + maybe (pure ()) analyzeExpr init + maybe (pure ()) analyzeExpr cond + maybe (pure ()) analyzeExpr update + analyzeStatements body + + StmtForeach expr keyVar valVar body -> do + analyzeExpr expr + -- The foreach variables are declared + declareVar (varName keyVar) pos + maybe (pure ()) (\v -> declareVar (varName v) pos) valVar + analyzeStatements body + + StmtSwitch expr cases -> do + analyzeExpr expr + forM_ cases $ \c -> do + maybe (pure ()) analyzeExpr (caseExpr c) + analyzeStatements (caseBody c) + + StmtMatch expr arms -> do + analyzeExpr expr + forM_ arms $ \arm -> do + mapM_ analyzeExpr (matchConditions arm) + analyzeExpr (matchResult arm) + + StmtTry tryBody catches finally -> do + analyzeStatements tryBody + forM_ catches $ \c -> do + -- Catch variable is declared + maybe (pure ()) (\v -> declareVar (varName v) pos) (catchVar c) + analyzeStatements (catchBody c) + maybe (pure ()) analyzeStatements finally + + StmtReturn mexpr -> maybe (pure ()) analyzeExpr mexpr + + StmtThrow expr -> analyzeExpr expr + + StmtEcho exprs -> mapM_ analyzeExpr exprs + + StmtGlobal vars -> forM_ vars $ \v -> useVar (varName v) + + StmtStatic pairs -> forM_ pairs $ \(v, mexpr) -> do + declareVar (varName v) pos + maybe (pure ()) analyzeExpr mexpr + + StmtUnset exprs -> mapM_ analyzeExpr exprs + + StmtDecl decl -> analyzeDeclaration pos decl + + StmtDeclare _ body -> analyzeStatements body + + _ -> pure () + +-- | Analyze a declaration +analyzeDeclaration :: SourcePos -> Declaration -> AnalysisM () +analyzeDeclaration pos decl = case decl of + DeclFunction{fnParams = params, fnBody = body} -> do + -- Track parameters + let paramState = foldl' (\m p -> Map.insert (varName (paramName p)) pos m) Map.empty params + -- Analyze body with fresh state for unused parameter detection + oldState <- get + put $ initialState { asDeclared = paramState } + analyzeStatements body + newState <- get + -- Report unused parameters + let unusedParams = Map.filterWithKey + (\k _ -> not (Set.member k (asUsed newState))) + paramState + forM_ (Map.toList unusedParams) $ \(name, ppos) -> + addIssue DeadCodeIssue + { dcType = UnusedParameter + , dcLocation = ppos + , dcDescription = "Parameter '$" <> name <> "' is never used" + , dcIdentifier = name + } + -- Restore outer state, keeping issues + put $ oldState { asIssues = asIssues newState ++ asIssues oldState } + + DeclClass{clsMembers = members} -> + forM_ members (analyzeClassMember pos) + + _ -> pure () + +-- | Analyze class members +analyzeClassMember :: SourcePos -> ClassMember -> AnalysisM () +analyzeClassMember pos member = case member of + MemberMethod{methParams = params, methBody = Just body} -> do + let paramState = foldl' (\m p -> Map.insert (varName (paramName p)) pos m) Map.empty params + oldState <- get + put $ initialState { asDeclared = paramState } + analyzeStatements body + newState <- get + -- Report unused parameters + let unusedParams = Map.filterWithKey + (\k _ -> not (Set.member k (asUsed newState))) + paramState + forM_ (Map.toList unusedParams) $ \(name, ppos) -> + addIssue DeadCodeIssue + { dcType = UnusedParameter + , dcLocation = ppos + , dcDescription = "Parameter '$" <> name <> "' is never used" + , dcIdentifier = name + } + put $ oldState { asIssues = asIssues newState ++ asIssues oldState } + + MemberProperty{propDefault = Just expr} -> analyzeExpr expr + + _ -> pure () + +-- | Analyze an expression, tracking variable usage +analyzeExpr :: Located Expr -> AnalysisM () +analyzeExpr (Located pos expr) = case expr of + ExprVariable (Variable name) -> + -- This is a variable read + useVar name + + ExprAssign target value -> do + -- Check if target is a simple variable assignment + case locNode target of + ExprVariable (Variable name) -> declareVar name pos + _ -> analyzeExpr target + analyzeExpr value + + ExprAssignOp _ target value -> do + -- Compound assignment both reads and writes + analyzeExpr target + analyzeExpr value + + ExprBinary _ left right -> do + analyzeExpr left + analyzeExpr right + + ExprUnary _ operand -> analyzeExpr operand + + ExprTernary cond mtrue false -> do + analyzeExpr cond + maybe (pure ()) analyzeExpr mtrue + analyzeExpr false + + ExprCall callee args -> do + analyzeExpr callee + mapM_ (analyzeExpr . argValue) args + + ExprMethodCall obj _ args -> do + analyzeExpr obj + mapM_ (analyzeExpr . argValue) args + + ExprStaticCall _ _ args -> + mapM_ (analyzeExpr . argValue) args + + ExprNullsafeMethodCall obj _ args -> do + analyzeExpr obj + mapM_ (analyzeExpr . argValue) args + + ExprPropertyAccess obj _ -> analyzeExpr obj + + ExprNullsafePropertyAccess obj _ -> analyzeExpr obj + + ExprArrayAccess base mindex -> do + analyzeExpr base + maybe (pure ()) analyzeExpr mindex + + ExprNew _ args -> + mapM_ (analyzeExpr . argValue) args + + ExprClosure{closureUses = uses, closureBody = body} -> do + -- Variables in use() clause are used in outer scope + forM_ uses $ \(v, _) -> useVar (varName v) + analyzeStatements body + + ExprArrowFunction{arrowExpr = e} -> analyzeExpr e + + ExprCast _ e -> analyzeExpr e + + ExprIsset exprs -> mapM_ analyzeExpr exprs + + ExprEmpty e -> analyzeExpr e + + ExprEval e -> analyzeExpr e + + ExprInclude _ e -> analyzeExpr e + + ExprYield mkey mval -> do + maybe (pure ()) analyzeExpr mkey + maybe (pure ()) analyzeExpr mval + + ExprYieldFrom e -> analyzeExpr e + + ExprThrow e -> analyzeExpr e + + ExprLiteral (LitArray pairs) -> + forM_ pairs $ \(mkey, val) -> do + maybe (pure ()) analyzeExpr mkey + analyzeExpr val + + ExprList exprs -> + forM_ exprs $ maybe (pure ()) analyzeExpr + + _ -> pure () + +-- | Find unused variables in a PHP file (convenience function) +findUnusedVariables :: PhpFile -> [DeadCodeIssue] +findUnusedVariables = filter isUnusedVar . analyzeDeadCode + where + isUnusedVar issue = dcType issue `elem` [UnusedVariable, UnusedParameter] + +-- | Find unreachable code in a PHP file (convenience function) +findUnreachableCode :: PhpFile -> [DeadCodeIssue] +findUnreachableCode = filter isUnreachable . analyzeDeadCode + where + isUnreachable issue = dcType issue == UnreachableCode diff --git a/test/Main.hs b/test/Main.hs new file mode 100644 index 0000000..67da713 --- /dev/null +++ b/test/Main.hs @@ -0,0 +1,159 @@ +-- | Test suite for Sanctify PHP +-- SPDX-License-Identifier: AGPL-3.0-or-later +module Main (main) where + +import Test.Hspec +import Data.Text (Text) +import qualified Data.Text as T + +import Sanctify.Parser +import Sanctify.AST +import Sanctify.Analysis.DeadCode + +main :: IO () +main = hspec $ do + describe "Sanctify.Analysis.DeadCode" $ do + deadCodeSpecs + +deadCodeSpecs :: Spec +deadCodeSpecs = do + describe "analyzeDeadCode" $ do + it "detects unused variables" $ do + let code = T.unlines + [ " expectationFailure $ "Parse error: " ++ show err + Right file -> do + let issues = analyzeDeadCode file + unusedVars = filter ((== UnusedVariable) . dcType) issues + length unusedVars `shouldBe` 1 + dcIdentifier (head unusedVars) `shouldBe` "unused" + + it "detects unreachable code after return" $ do + let code = T.unlines + [ " expectationFailure $ "Parse error: " ++ show err + Right file -> do + let issues = analyzeDeadCode file + unreachable = filter ((== UnreachableCode) . dcType) issues + length unreachable `shouldBe` 1 + + it "detects unreachable code after throw" $ do + let code = T.unlines + [ " expectationFailure $ "Parse error: " ++ show err + Right file -> do + let issues = analyzeDeadCode file + unreachable = filter ((== UnreachableCode) . dcType) issues + length unreachable `shouldBe` 1 + + it "detects unused function parameters" $ do + let code = T.unlines + [ " expectationFailure $ "Parse error: " ++ show err + Right file -> do + let issues = analyzeDeadCode file + unusedParams = filter ((== UnusedParameter) . dcType) issues + length unusedParams `shouldBe` 1 + dcIdentifier (head unusedParams) `shouldBe` "age" + + it "returns no issues for clean code" $ do + let code = T.unlines + [ " expectationFailure $ "Parse error: " ++ show err + Right file -> do + let issues = analyzeDeadCode file + issues `shouldBe` [] + + it "handles variables in closures correctly" $ do + let code = T.unlines + [ " expectationFailure $ "Parse error: " ++ show err + Right file -> do + let issues = analyzeDeadCode file + unusedVars = filter ((== UnusedVariable) . dcType) issues + -- $outer and $result should be marked as used, $fn should be used + unusedVars `shouldBe` [] + + it "detects multiple unused variables" $ do + let code = T.unlines + [ " expectationFailure $ "Parse error: " ++ show err + Right file -> do + let issues = analyzeDeadCode file + unusedVars = filter ((== UnusedVariable) . dcType) issues + length unusedVars `shouldBe` 2 + + describe "findUnusedVariables" $ do + it "filters only variable-related issues" $ do + let code = T.unlines + [ " expectationFailure $ "Parse error: " ++ show err + Right file -> do + let issues = findUnusedVariables file + -- Should have unused parameter and unused variable, but not unreachable + all (\i -> dcType i `elem` [UnusedVariable, UnusedParameter]) issues + `shouldBe` True + + describe "findUnreachableCode" $ do + it "filters only unreachable code issues" $ do + let code = T.unlines + [ " expectationFailure $ "Parse error: " ++ show err + Right file -> do + let issues = findUnreachableCode file + all (\i -> dcType i == UnreachableCode) issues `shouldBe` True + length issues `shouldBe` 1 diff --git a/test/fixtures/clean_code.php b/test/fixtures/clean_code.php new file mode 100644 index 0000000..0d60a39 --- /dev/null +++ b/test/fixtures/clean_code.php @@ -0,0 +1,62 @@ +users = []; + } + + public function addUser(string $name, string $email): int + { + $id = count($this->users) + 1; + $this->users[$id] = [ + 'name' => $name, + 'email' => $email, + ]; + return $id; + } + + public function getUser(int $id): ?array + { + if (isset($this->users[$id])) { + return $this->users[$id]; + } + return null; + } + + public function listUsers(): array + { + $result = []; + foreach ($this->users as $id => $user) { + $result[] = [ + 'id' => $id, + 'name' => $user['name'], + ]; + } + return $result; + } +} + +// Function with all parameters used +function formatUser(array $user, string $format): string +{ + $name = $user['name']; + $email = $user['email']; + + if ($format === 'full') { + return $name . ' <' . $email . '>'; + } + return $name; +} diff --git a/test/fixtures/unreachable_code.php b/test/fixtures/unreachable_code.php new file mode 100644 index 0000000..6430896 --- /dev/null +++ b/test/fixtures/unreachable_code.php @@ -0,0 +1,50 @@ + Date: Sat, 27 Dec 2025 18:11:15 +0000 Subject: [PATCH 2/2] feat: add ruleset management for configurable static analysis Add a comprehensive ruleset management system that allows: - Enabling/disabling individual rules by ID - Setting rule severity levels (Off, Info, Warning, Error, Critical) - Organizing rules by category (Security, DeadCode, Types, WordPress, etc.) - Predefined rulesets: strict, security, wordpress, minimal, default - Ruleset merging for inheritance-style configuration - JSON serialization for loading/saving rulesets Includes 30+ rule definitions across 7 categories and comprehensive tests. --- sanctify-php.cabal | 4 +- src/Sanctify/Ruleset.hs | 445 +++++++++++++++++++++++++++++ test/Main.hs | 97 +++++++ test/fixtures/custom-ruleset.json | 36 +++ test/fixtures/minimal-ruleset.json | 21 ++ 5 files changed, 602 insertions(+), 1 deletion(-) create mode 100644 src/Sanctify/Ruleset.hs create mode 100644 test/fixtures/custom-ruleset.json create mode 100644 test/fixtures/minimal-ruleset.json diff --git a/sanctify-php.cabal b/sanctify-php.cabal index 517cd67..9b12c28 100644 --- a/sanctify-php.cabal +++ b/sanctify-php.cabal @@ -41,6 +41,7 @@ library Sanctify.Emit Sanctify.Config Sanctify.Report + Sanctify.Ruleset other-modules: Sanctify.Parser.Lexer Sanctify.Parser.Token @@ -96,4 +97,5 @@ test-suite sanctify-php-test sanctify-php, hspec >=2.10, hspec-megaparsec >=2.2, - text + text, + containers diff --git a/src/Sanctify/Ruleset.hs b/src/Sanctify/Ruleset.hs new file mode 100644 index 0000000..34c9b61 --- /dev/null +++ b/src/Sanctify/Ruleset.hs @@ -0,0 +1,445 @@ +-- | Ruleset management for sanctify-php +-- Provides configurable rule collections for static analysis +-- SPDX-License-Identifier: AGPL-3.0-or-later +module Sanctify.Ruleset + ( -- * Core types + Ruleset(..) + , RuleConfig(..) + , RuleId(..) + , RuleSeverity(..) + , RuleCategory(..) + + -- * Ruleset operations + , createRuleset + , mergeRulesets + , enableRule + , disableRule + , setRuleSeverity + , isRuleEnabled + , getRuleConfig + + -- * Predefined rulesets + , strictRuleset + , securityRuleset + , wordpressRuleset + , minimalRuleset + , defaultRuleset + + -- * Rule definitions + , allRules + , rulesByCategory + , getRuleInfo + , RuleInfo(..) + + -- * Serialization + , loadRuleset + , saveRuleset + , parseRulesetYaml + , getPredefinedRuleset + ) where + +import Data.Text (Text) +import qualified Data.Text as T +import qualified Data.Text.IO as TIO +import qualified Data.Text.Encoding as TE +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as Map +import Data.Set (Set) +import qualified Data.Set as Set +import Data.Aeson +import qualified Data.ByteString.Lazy as BL +import GHC.Generics (Generic) +import Data.Maybe (fromMaybe) +import Control.Monad (forM) + +-- | Unique identifier for a rule +newtype RuleId = RuleId { unRuleId :: Text } + deriving stock (Eq, Ord, Show, Generic) + deriving anyclass (ToJSON, FromJSON) + deriving newtype (ToJSONKey, FromJSONKey) + +-- | Severity level for rules +data RuleSeverity + = SeverityOff -- ^ Rule is disabled + | SeverityInfo -- ^ Informational only + | SeverityWarning -- ^ Warning, may not be an issue + | SeverityError -- ^ Error, should be fixed + | SeverityCritical -- ^ Critical security issue + deriving stock (Eq, Ord, Show, Generic) + deriving anyclass (ToJSON, FromJSON) + +-- | Categories of rules +data RuleCategory + = CategorySecurity -- ^ Security vulnerabilities + | CategoryDeadCode -- ^ Dead/unreachable code + | CategoryTypes -- ^ Type-related issues + | CategoryWordPress -- ^ WordPress-specific + | CategoryPerformance -- ^ Performance issues + | CategoryStyle -- ^ Code style + | CategoryMaintenance -- ^ Maintainability + deriving stock (Eq, Ord, Show, Generic, Enum, Bounded) + deriving anyclass (ToJSON, FromJSON) + +-- | Configuration for a single rule +data RuleConfig = RuleConfig + { rcEnabled :: Bool -- ^ Is the rule active? + , rcSeverity :: RuleSeverity -- ^ Override severity + , rcOptions :: Map Text Value -- ^ Rule-specific options + } + deriving stock (Eq, Show, Generic) + deriving anyclass (ToJSON, FromJSON) + +-- | Default rule configuration +defaultRuleConfig :: RuleConfig +defaultRuleConfig = RuleConfig + { rcEnabled = True + , rcSeverity = SeverityWarning + , rcOptions = Map.empty + } + +-- | Information about a rule +data RuleInfo = RuleInfo + { riId :: RuleId + , riName :: Text + , riDescription :: Text + , riCategory :: RuleCategory + , riDefault :: RuleSeverity + , riAutoFixable :: Bool + } + deriving stock (Eq, Show, Generic) + deriving anyclass (ToJSON, FromJSON) + +-- | A complete ruleset +data Ruleset = Ruleset + { rsName :: Text -- ^ Ruleset name + , rsDescription :: Text -- ^ Description + , rsExtends :: Maybe Text -- ^ Parent ruleset name + , rsRules :: Map RuleId RuleConfig -- ^ Rule configurations + , rsCategories :: Map RuleCategory Bool -- ^ Enable/disable categories + } + deriving stock (Eq, Show, Generic) + deriving anyclass (ToJSON, FromJSON) + +-- | All available rules in the system +allRules :: [RuleInfo] +allRules = + -- Security rules + [ RuleInfo (RuleId "SEC001") "sql-injection" + "Detect potential SQL injection vulnerabilities" + CategorySecurity SeverityCritical True + , RuleInfo (RuleId "SEC002") "xss" + "Detect potential cross-site scripting (XSS) vulnerabilities" + CategorySecurity SeverityCritical True + , RuleInfo (RuleId "SEC003") "command-injection" + "Detect potential command injection via shell execution" + CategorySecurity SeverityCritical False + , RuleInfo (RuleId "SEC004") "path-traversal" + "Detect potential path traversal in file operations" + CategorySecurity SeverityError True + , RuleInfo (RuleId "SEC005") "unsafe-deserialization" + "Detect unsafe use of unserialize()" + CategorySecurity SeverityError True + , RuleInfo (RuleId "SEC006") "weak-crypto" + "Detect use of weak cryptographic functions (md5, sha1)" + CategorySecurity SeverityWarning True + , RuleInfo (RuleId "SEC007") "hardcoded-secrets" + "Detect hardcoded passwords, API keys, and secrets" + CategorySecurity SeverityError False + , RuleInfo (RuleId "SEC008") "insecure-random" + "Detect use of insecure random number generators" + CategorySecurity SeverityWarning True + , RuleInfo (RuleId "SEC009") "eval-usage" + "Detect use of eval() and similar dynamic code execution" + CategorySecurity SeverityCritical False + , RuleInfo (RuleId "SEC010") "missing-strict-types" + "Detect missing declare(strict_types=1)" + CategorySecurity SeverityInfo True + + -- Dead code rules + , RuleInfo (RuleId "DEAD001") "unused-variable" + "Detect variables that are assigned but never used" + CategoryDeadCode SeverityWarning False + , RuleInfo (RuleId "DEAD002") "unreachable-code" + "Detect code after return, throw, break, or continue" + CategoryDeadCode SeverityWarning False + , RuleInfo (RuleId "DEAD003") "unused-parameter" + "Detect function parameters that are never used" + CategoryDeadCode SeverityInfo False + , RuleInfo (RuleId "DEAD004") "unused-import" + "Detect use statements that are never referenced" + CategoryDeadCode SeverityWarning False + + -- Type rules + , RuleInfo (RuleId "TYPE001") "missing-param-type" + "Detect function parameters without type hints" + CategoryTypes SeverityInfo True + , RuleInfo (RuleId "TYPE002") "missing-return-type" + "Detect functions without return type declarations" + CategoryTypes SeverityInfo True + , RuleInfo (RuleId "TYPE003") "missing-property-type" + "Detect class properties without type declarations" + CategoryTypes SeverityInfo True + , RuleInfo (RuleId "TYPE004") "type-coercion-risk" + "Detect potential type coercion issues" + CategoryTypes SeverityWarning False + + -- WordPress rules + , RuleInfo (RuleId "WP001") "missing-escaping" + "Detect unescaped output in WordPress context" + CategoryWordPress SeverityError True + , RuleInfo (RuleId "WP002") "missing-sanitization" + "Detect unsanitized input in WordPress context" + CategoryWordPress SeverityError True + , RuleInfo (RuleId "WP003") "missing-nonce" + "Detect form handlers without nonce verification" + CategoryWordPress SeverityError True + , RuleInfo (RuleId "WP004") "missing-capability-check" + "Detect privileged operations without capability checks" + CategoryWordPress SeverityError False + , RuleInfo (RuleId "WP005") "direct-db-query" + "Detect direct database queries without prepare()" + CategoryWordPress SeverityError True + , RuleInfo (RuleId "WP006") "missing-text-domain" + "Detect translatable strings without text domain" + CategoryWordPress SeverityWarning True + , RuleInfo (RuleId "WP007") "unprefixed-function" + "Detect global functions without plugin prefix" + CategoryWordPress SeverityWarning False + , RuleInfo (RuleId "WP008") "deprecated-function" + "Detect use of deprecated WordPress functions" + CategoryWordPress SeverityWarning True + + -- Performance rules + , RuleInfo (RuleId "PERF001") "n-plus-one-query" + "Detect potential N+1 query patterns" + CategoryPerformance SeverityWarning False + , RuleInfo (RuleId "PERF002") "large-array-in-loop" + "Detect large array operations inside loops" + CategoryPerformance SeverityInfo False + + -- Style rules + , RuleInfo (RuleId "STYLE001") "mixed-tabs-spaces" + "Detect mixed indentation styles" + CategoryStyle SeverityInfo False + , RuleInfo (RuleId "STYLE002") "long-function" + "Detect functions exceeding line limit" + CategoryStyle SeverityInfo False + + -- Maintenance rules + , RuleInfo (RuleId "MAINT001") "todo-comment" + "Detect TODO/FIXME comments" + CategoryMaintenance SeverityInfo False + , RuleInfo (RuleId "MAINT002") "complex-condition" + "Detect overly complex conditional expressions" + CategoryMaintenance SeverityInfo False + ] + +-- | Get rules by category +rulesByCategory :: RuleCategory -> [RuleInfo] +rulesByCategory cat = filter ((== cat) . riCategory) allRules + +-- | Get information about a specific rule +getRuleInfo :: RuleId -> Maybe RuleInfo +getRuleInfo rid = lookup rid [(riId r, r) | r <- allRules] + +-- | Create a new ruleset from a list of enabled rules +createRuleset :: Text -> Text -> [RuleId] -> Ruleset +createRuleset name desc ruleIds = Ruleset + { rsName = name + , rsDescription = desc + , rsExtends = Nothing + , rsRules = Map.fromList + [ (rid, defaultRuleConfig { rcEnabled = rid `elem` ruleIds }) + | RuleInfo{riId = rid} <- allRules + ] + , rsCategories = Map.fromList [(cat, True) | cat <- [minBound..maxBound]] + } + +-- | Merge two rulesets (second overrides first) +mergeRulesets :: Ruleset -> Ruleset -> Ruleset +mergeRulesets base override = Ruleset + { rsName = rsName override + , rsDescription = rsDescription override + , rsExtends = rsExtends override + , rsRules = Map.unionWith mergeRuleConfig (rsRules base) (rsRules override) + , rsCategories = Map.union (rsCategories override) (rsCategories base) + } + where + mergeRuleConfig _ new = new + +-- | Enable a specific rule +enableRule :: RuleId -> Ruleset -> Ruleset +enableRule rid rs = rs + { rsRules = Map.alter enable rid (rsRules rs) } + where + enable Nothing = Just defaultRuleConfig + enable (Just rc) = Just rc { rcEnabled = True } + +-- | Disable a specific rule +disableRule :: RuleId -> Ruleset -> Ruleset +disableRule rid rs = rs + { rsRules = Map.alter disable rid (rsRules rs) } + where + disable Nothing = Just defaultRuleConfig { rcEnabled = False } + disable (Just rc) = Just rc { rcEnabled = False } + +-- | Set severity for a specific rule +setRuleSeverity :: RuleId -> RuleSeverity -> Ruleset -> Ruleset +setRuleSeverity rid sev rs = rs + { rsRules = Map.alter setSev rid (rsRules rs) } + where + setSev Nothing = Just defaultRuleConfig { rcSeverity = sev } + setSev (Just rc) = Just rc { rcSeverity = sev } + +-- | Check if a rule is enabled +isRuleEnabled :: RuleId -> Ruleset -> Bool +isRuleEnabled rid rs = + case Map.lookup rid (rsRules rs) of + Nothing -> True -- Default enabled + Just rc -> rcEnabled rc && categoryEnabled + where + categoryEnabled = case getRuleInfo rid of + Nothing -> True + Just info -> fromMaybe True $ Map.lookup (riCategory info) (rsCategories rs) + +-- | Get configuration for a rule +getRuleConfig :: RuleId -> Ruleset -> RuleConfig +getRuleConfig rid rs = fromMaybe defaultRuleConfig $ Map.lookup rid (rsRules rs) + +-- ============================================================================ +-- Predefined Rulesets +-- ============================================================================ + +-- | Strict ruleset - all rules enabled with high severity +strictRuleset :: Ruleset +strictRuleset = Ruleset + { rsName = "strict" + , rsDescription = "All rules enabled with elevated severity" + , rsExtends = Nothing + , rsRules = Map.fromList + [ (riId info, RuleConfig True (elevate (riDefault info)) Map.empty) + | info <- allRules + ] + , rsCategories = Map.fromList [(cat, True) | cat <- [minBound..maxBound]] + } + where + elevate SeverityInfo = SeverityWarning + elevate SeverityWarning = SeverityError + elevate other = other + +-- | Security-focused ruleset +securityRuleset :: Ruleset +securityRuleset = Ruleset + { rsName = "security" + , rsDescription = "Security-focused rules only" + , rsExtends = Nothing + , rsRules = Map.fromList + [ (riId info, RuleConfig (riCategory info == CategorySecurity) (riDefault info) Map.empty) + | info <- allRules + ] + , rsCategories = Map.fromList + [ (CategorySecurity, True) + , (CategoryDeadCode, False) + , (CategoryTypes, False) + , (CategoryWordPress, False) + , (CategoryPerformance, False) + , (CategoryStyle, False) + , (CategoryMaintenance, False) + ] + } + +-- | WordPress-specific ruleset +wordpressRuleset :: Ruleset +wordpressRuleset = Ruleset + { rsName = "wordpress" + , rsDescription = "WordPress plugin/theme development rules" + , rsExtends = Nothing + , rsRules = Map.fromList + [ (riId info, RuleConfig enabled (riDefault info) Map.empty) + | info <- allRules + , let enabled = riCategory info `elem` + [CategorySecurity, CategoryWordPress, CategoryDeadCode] + ] + , rsCategories = Map.fromList + [ (CategorySecurity, True) + , (CategoryDeadCode, True) + , (CategoryTypes, True) + , (CategoryWordPress, True) + , (CategoryPerformance, True) + , (CategoryStyle, False) + , (CategoryMaintenance, False) + ] + } + +-- | Minimal ruleset - only critical security issues +minimalRuleset :: Ruleset +minimalRuleset = Ruleset + { rsName = "minimal" + , rsDescription = "Only critical security issues" + , rsExtends = Nothing + , rsRules = Map.fromList + [ (riId info, RuleConfig (riDefault info >= SeverityError) (riDefault info) Map.empty) + | info <- allRules + ] + , rsCategories = Map.fromList + [ (CategorySecurity, True) + , (CategoryDeadCode, False) + , (CategoryTypes, False) + , (CategoryWordPress, False) + , (CategoryPerformance, False) + , (CategoryStyle, False) + , (CategoryMaintenance, False) + ] + } + +-- | Default ruleset - balanced settings +defaultRuleset :: Ruleset +defaultRuleset = Ruleset + { rsName = "default" + , rsDescription = "Balanced default settings" + , rsExtends = Nothing + , rsRules = Map.fromList + [ (riId info, RuleConfig True (riDefault info) Map.empty) + | info <- allRules + ] + , rsCategories = Map.fromList + [ (CategorySecurity, True) + , (CategoryDeadCode, True) + , (CategoryTypes, True) + , (CategoryWordPress, False) -- Auto-detected + , (CategoryPerformance, False) + , (CategoryStyle, False) + , (CategoryMaintenance, False) + ] + } + +-- ============================================================================ +-- Serialization +-- ============================================================================ + +-- | Load a ruleset from a JSON/YAML file +loadRuleset :: FilePath -> IO (Either String Ruleset) +loadRuleset path = do + content <- TIO.readFile path + pure $ parseRulesetYaml content + +-- | Save a ruleset to a file +saveRuleset :: FilePath -> Ruleset -> IO () +saveRuleset path rs = BL.writeFile path (encode rs) + +-- | Parse a ruleset from YAML/JSON text +parseRulesetYaml :: Text -> Either String Ruleset +parseRulesetYaml content = + case eitherDecodeStrict' (TE.encodeUtf8 content) of + Left err -> Left $ "Failed to parse ruleset: " ++ err + Right rs -> Right rs + +-- | Get a predefined ruleset by name +getPredefinedRuleset :: Text -> Maybe Ruleset +getPredefinedRuleset name = case T.toLower name of + "strict" -> Just strictRuleset + "security" -> Just securityRuleset + "wordpress" -> Just wordpressRuleset + "minimal" -> Just minimalRuleset + "default" -> Just defaultRuleset + _ -> Nothing diff --git a/test/Main.hs b/test/Main.hs index 67da713..5336923 100644 --- a/test/Main.hs +++ b/test/Main.hs @@ -5,15 +5,19 @@ module Main (main) where import Test.Hspec import Data.Text (Text) import qualified Data.Text as T +import qualified Data.Map.Strict as Map import Sanctify.Parser import Sanctify.AST import Sanctify.Analysis.DeadCode +import Sanctify.Ruleset main :: IO () main = hspec $ do describe "Sanctify.Analysis.DeadCode" $ do deadCodeSpecs + describe "Sanctify.Ruleset" $ do + rulesetSpecs deadCodeSpecs :: Spec deadCodeSpecs = do @@ -157,3 +161,96 @@ deadCodeSpecs = do let issues = findUnreachableCode file all (\i -> dcType i == UnreachableCode) issues `shouldBe` True length issues `shouldBe` 1 + +rulesetSpecs :: Spec +rulesetSpecs = do + describe "predefined rulesets" $ do + it "defaultRuleset has security rules enabled" $ do + let rs = defaultRuleset + isRuleEnabled (RuleId "SEC001") rs `shouldBe` True + isRuleEnabled (RuleId "SEC002") rs `shouldBe` True + + it "minimalRuleset disables low-severity rules" $ do + let rs = minimalRuleset + -- Dead code rules should be disabled in minimal + isRuleEnabled (RuleId "DEAD001") rs `shouldBe` False + + it "strictRuleset elevates severity levels" $ do + let rs = strictRuleset + cfg = getRuleConfig (RuleId "SEC010") rs + -- Missing strict_types should be elevated from Info + rcSeverity cfg `shouldSatisfy` (> SeverityInfo) + + it "securityRuleset only enables security category" $ do + let rs = securityRuleset + isRuleEnabled (RuleId "SEC001") rs `shouldBe` True + isRuleEnabled (RuleId "DEAD001") rs `shouldBe` False + isRuleEnabled (RuleId "TYPE001") rs `shouldBe` False + + it "wordpressRuleset enables WP rules" $ do + let rs = wordpressRuleset + isRuleEnabled (RuleId "WP001") rs `shouldBe` True + isRuleEnabled (RuleId "WP002") rs `shouldBe` True + + describe "ruleset operations" $ do + it "enableRule enables a disabled rule" $ do + let rs = disableRule (RuleId "SEC001") defaultRuleset + isRuleEnabled (RuleId "SEC001") rs `shouldBe` False + let rs' = enableRule (RuleId "SEC001") rs + isRuleEnabled (RuleId "SEC001") rs' `shouldBe` True + + it "disableRule disables an enabled rule" $ do + let rs = disableRule (RuleId "SEC001") defaultRuleset + isRuleEnabled (RuleId "SEC001") rs `shouldBe` False + + it "setRuleSeverity changes rule severity" $ do + let rs = setRuleSeverity (RuleId "SEC001") SeverityWarning defaultRuleset + cfg = getRuleConfig (RuleId "SEC001") rs + rcSeverity cfg `shouldBe` SeverityWarning + + it "mergeRulesets overrides rules from base" $ do + let base = defaultRuleset + override = disableRule (RuleId "SEC001") $ + createRuleset "override" "test" [] + merged = mergeRulesets base override + isRuleEnabled (RuleId "SEC001") merged `shouldBe` False + + describe "rule definitions" $ do + it "allRules contains security rules" $ do + let secRules = rulesByCategory CategorySecurity + length secRules `shouldSatisfy` (> 0) + all ((== CategorySecurity) . riCategory) secRules `shouldBe` True + + it "allRules contains dead code rules" $ do + let deadRules = rulesByCategory CategoryDeadCode + length deadRules `shouldSatisfy` (>= 4) + + it "getRuleInfo returns correct info" $ do + case getRuleInfo (RuleId "SEC001") of + Nothing -> expectationFailure "Rule SEC001 not found" + Just info -> do + riCategory info `shouldBe` CategorySecurity + riAutoFixable info `shouldBe` True + + it "getRuleInfo returns Nothing for unknown rule" $ do + getRuleInfo (RuleId "UNKNOWN999") `shouldBe` Nothing + + describe "createRuleset" $ do + it "creates ruleset with specified rules enabled" $ do + let rs = createRuleset "test" "Test ruleset" + [RuleId "SEC001", RuleId "SEC002"] + isRuleEnabled (RuleId "SEC001") rs `shouldBe` True + isRuleEnabled (RuleId "SEC002") rs `shouldBe` True + + describe "getPredefinedRuleset" $ do + it "returns strict ruleset" $ do + case getPredefinedRuleset "strict" of + Nothing -> expectationFailure "strict ruleset not found" + Just rs -> rsName rs `shouldBe` "strict" + + it "returns Nothing for unknown ruleset" $ do + getPredefinedRuleset "nonexistent" `shouldBe` Nothing + + it "is case-insensitive" $ do + getPredefinedRuleset "STRICT" `shouldSatisfy` (/= Nothing) + getPredefinedRuleset "WordPress" `shouldSatisfy` (/= Nothing) diff --git a/test/fixtures/custom-ruleset.json b/test/fixtures/custom-ruleset.json new file mode 100644 index 0000000..5d0a1ab --- /dev/null +++ b/test/fixtures/custom-ruleset.json @@ -0,0 +1,36 @@ +{ + "rsName": "custom", + "rsDescription": "Custom test ruleset", + "rsExtends": null, + "rsRules": { + "SEC001": { + "rcEnabled": true, + "rcSeverity": "SeverityCritical", + "rcOptions": {} + }, + "SEC002": { + "rcEnabled": true, + "rcSeverity": "SeverityCritical", + "rcOptions": {} + }, + "DEAD001": { + "rcEnabled": false, + "rcSeverity": "SeverityWarning", + "rcOptions": {} + }, + "DEAD002": { + "rcEnabled": true, + "rcSeverity": "SeverityError", + "rcOptions": {} + } + }, + "rsCategories": { + "CategorySecurity": true, + "CategoryDeadCode": true, + "CategoryTypes": false, + "CategoryWordPress": false, + "CategoryPerformance": false, + "CategoryStyle": false, + "CategoryMaintenance": false + } +} diff --git a/test/fixtures/minimal-ruleset.json b/test/fixtures/minimal-ruleset.json new file mode 100644 index 0000000..32d4e8b --- /dev/null +++ b/test/fixtures/minimal-ruleset.json @@ -0,0 +1,21 @@ +{ + "rsName": "minimal-test", + "rsDescription": "Minimal ruleset for testing", + "rsExtends": null, + "rsRules": { + "SEC001": { + "rcEnabled": true, + "rcSeverity": "SeverityCritical", + "rcOptions": {} + } + }, + "rsCategories": { + "CategorySecurity": true, + "CategoryDeadCode": false, + "CategoryTypes": false, + "CategoryWordPress": false, + "CategoryPerformance": false, + "CategoryStyle": false, + "CategoryMaintenance": false + } +}