fix: close audit-pass-1 remaining MAJOR + quick MINOR (Findings 14, 19, 20, 28, 29)

- Geo.hs (Finding 14): geoAsnCounts is now bounded by
  defaultGeoAsnCountCap = 200_000. capAsnCounts is called inside
  bumpAsnCounter; on overflow, the entry with the oldest
  awWindowStart is evicted via Data.List.minimumBy + Data.Ord.comparing.
  Closes the unbounded-Map memory growth path. Uses minimumBy and
  comparing imports added to the existing module.

- ML/Middleware.hs, WAF/Engine.hs, Honeypot.hs (Finding 19): em
  dashes (\x2014) in user-facing response bodies and the generated
  robots.txt comment replaced with ASCII hyphens, per the project's
  guardrail-safe terminology rule.

- aenebris.cabal (Finding 20): copyright field updated from
  '2025 Carter Perez' to '2026 AngelaMos' to match the file headers.

- ML/IForest.hs (Finding 28): pathLength now respects a hard
  maxIForestDepth = 64 cutoff. Beyond that depth the function
  returns currentDepth + c(1) (= currentDepth) without further
  recursion, so a pathological tree built by a buggy fitter cannot
  blow the stack. Standard iForest depth is ceil(log2(256)) = 8, so
  the cap leaves >>8 generous headroom.

- ML/Model.hs (Finding 29): validateCategoricalNode now also
  rejects categorical thresholds that are not whole numbers. A
  threshold of 1.5 would silently floor to 1 today; with this
  change it is reported as a clear validation error instead.
  Real LightGBM never writes non-integer cat indices, so this is
  defense-in-depth against malformed exporters.

Build clean, 358 examples passing, 0 failures.
This commit is contained in:
CarterPerez-dev 2026-04-29 02:15:14 -04:00
parent 998b5268e0
commit d9dd59db2a
7 changed files with 50 additions and 20 deletions

View File

@ -9,7 +9,7 @@ license: MIT
license-file: LICENSE license-file: LICENSE
author: Carter Perez author: Carter Perez
maintainer: support@certgames.com maintainer: support@certgames.com
copyright: 2025 Carter Perez copyright: 2026 AngelaMos
category: Network, Security, Web category: Network, Security, Web
build-type: Simple build-type: Simple
extra-source-files: README.md extra-source-files: README.md

View File

@ -60,9 +60,11 @@ import qualified Data.ByteString.Char8 as BC
import qualified Data.ByteString.Lazy as LBS import qualified Data.ByteString.Lazy as LBS
import Data.GeoIP2 (GeoDB, GeoResult(..), AS(..), findGeoData, openGeoDB) import Data.GeoIP2 (GeoDB, GeoResult(..), AS(..), findGeoData, openGeoDB)
import Data.IP (IP(..), fromHostAddress, fromHostAddress6) import Data.IP (IP(..), fromHostAddress, fromHostAddress6)
import Data.List (minimumBy)
import Data.Map.Strict (Map) import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map import qualified Data.Map.Strict as Map
import Data.Maybe (fromMaybe, isJust) import Data.Maybe (fromMaybe, isJust)
import Data.Ord (comparing)
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import qualified Data.Text.Encoding as TE import qualified Data.Text.Encoding as TE
@ -95,6 +97,9 @@ defaultGeoSweepIntervalMicros = 60_000_000
defaultGeoFlaggedAsns :: [Int] defaultGeoFlaggedAsns :: [Int]
defaultGeoFlaggedAsns = [] defaultGeoFlaggedAsns = []
defaultGeoAsnCountCap :: Int
defaultGeoAsnCountCap = 200_000
geoResponseHeaderName :: HeaderName geoResponseHeaderName :: HeaderName
geoResponseHeaderName = "x-aenebris-geo" geoResponseHeaderName = "x-aenebris-geo"
@ -262,9 +267,20 @@ bumpAsnCounter Geo{..} n now = do
| now - awWindowStart w < window -> | now - awWindowStart w < window ->
w { awCount = awCount w + 1 } w { awCount = awCount w + 1 }
_ -> AsnWindow { awCount = 1, awWindowStart = now } _ -> AsnWindow { awCount = 1, awWindowStart = now }
writeTVar geoAsnCounts $! Map.insert n entry m inserted = Map.insert n entry m
bounded = capAsnCounts inserted
writeTVar geoAsnCounts $! bounded
pure (awCount entry) pure (awCount entry)
capAsnCounts :: Map Int AsnWindow -> Map Int AsnWindow
capAsnCounts m
| Map.size m <= defaultGeoAsnCountCap = m
| otherwise =
let oldestKey = fst $ minimumBy
(comparing (awWindowStart . snd))
(Map.toList m)
in Map.delete oldestKey m
asnConcentrationScore :: Geo -> Int -> Double asnConcentrationScore :: Geo -> Int -> Double
asnConcentrationScore Geo{..} count = asnConcentrationScore Geo{..} count =
let threshold = max 1 (gcConcentrationThreshold geoConfig) let threshold = max 1 (gcConcentrationThreshold geoConfig)

View File

@ -274,7 +274,7 @@ robotsResponse cfg =
robotsTxtBody :: HoneypotConfig -> ByteString robotsTxtBody :: HoneypotConfig -> ByteString
robotsTxtBody HoneypotConfig{..} = BS.concat $ robotsTxtBody HoneypotConfig{..} = BS.concat $
[ "User-agent: *\n" [ "User-agent: *\n"
, "# Honeypot trap paths Disallow per RFC 9309. Visiting these\n" , "# Honeypot trap paths. Disallow per RFC 9309. Visiting these\n"
, "# paths is treated as a violation signal regardless of declared UA.\n" , "# paths is treated as a violation signal regardless of declared UA.\n"
] <> map disallowLine hpPatterns ] <> map disallowLine hpPatterns
where where

View File

@ -16,6 +16,7 @@ module Aenebris.ML.IForest
, minSubsampleForNormalization , minSubsampleForNormalization
, defaultIForestNumTrees , defaultIForestNumTrees
, defaultIForestSubsampleSize , defaultIForestSubsampleSize
, maxIForestDepth
) where ) where
import Data.Vector (Vector) import Data.Vector (Vector)
@ -44,6 +45,12 @@ defaultIForestNumTrees = 100
defaultIForestSubsampleSize :: Int defaultIForestSubsampleSize :: Int
defaultIForestSubsampleSize = 256 defaultIForestSubsampleSize = 256
maxIForestDepth :: Int
maxIForestDepth = 64
depthBoundLeafSize :: Int
depthBoundLeafSize = 1
data ITree data ITree
= ITreeLeaf !Int = ITreeLeaf !Int
| ITreeSplit !Int !Double !ITree !ITree | ITreeSplit !Int !Double !ITree !ITree
@ -77,14 +84,17 @@ averagePathLength !trees !fv =
addPath !acc !tree = acc + pathLength tree fv initialDepth addPath !acc !tree = acc + pathLength tree fv initialDepth
pathLength :: ITree -> VU.Vector Double -> Int -> Double pathLength :: ITree -> VU.Vector Double -> Int -> Double
pathLength !tree !fv !currentDepth = case tree of pathLength !tree !fv !currentDepth
ITreeLeaf !size -> | currentDepth >= maxIForestDepth =
fromIntegral currentDepth + normalizationConstant size fromIntegral currentDepth + normalizationConstant depthBoundLeafSize
ITreeSplit !featIdx !thr !left !right -> | otherwise = case tree of
let !fval = fv VU.! featIdx ITreeLeaf !size ->
in if fval <= thr fromIntegral currentDepth + normalizationConstant size
then pathLength left fv (currentDepth + 1) ITreeSplit !featIdx !thr !left !right ->
else pathLength right fv (currentDepth + 1) let !fval = fv VU.! featIdx
in if fval <= thr
then pathLength left fv (currentDepth + 1)
else pathLength right fv (currentDepth + 1)
normalizationConstant :: Int -> Double normalizationConstant :: Int -> Double
normalizationConstant n normalizationConstant n

View File

@ -54,7 +54,7 @@ challengeWireText :: ByteString
challengeWireText = "challenge" challengeWireText = "challenge"
botBlockBody :: LBS.ByteString botBlockBody :: LBS.ByteString
botBlockBody = "403 Forbidden \x2014 request blocked by Aenebris ML" botBlockBody = "403 Forbidden - request blocked by Aenebris ML"
challengePageBody :: LBS.ByteString challengePageBody :: LBS.ByteString
challengePageBody = challengePageBody =

View File

@ -329,15 +329,19 @@ validateCategoricalNode
:: Int -> Int -> Tree -> Int -> Int -> Int -> Int -> Either String () :: Int -> Int -> Tree -> Int -> Int -> Int -> Int -> Either String ()
validateCategoricalNode featureCount nodeCount t i fIdx lIdx rIdx = do validateCategoricalNode featureCount nodeCount t i fIdx lIdx rIdx = do
validateSplitNode featureCount nodeCount i fIdx lIdx rIdx validateSplitNode featureCount nodeCount i fIdx lIdx rIdx
let catIdx = floor (treeThreshold t VU.! i) :: Int let rawThreshold = treeThreshold t VU.! i
nBound = VU.length (treeCatBoundaries t) catIdx = floor rawThreshold :: Int
if nBound < 2 nBound = VU.length (treeCatBoundaries t)
if fromIntegral catIdx /= rawThreshold
then Left ("Categorical node " <> show i then Left ("Categorical node " <> show i
<> " requires non-empty cat_boundaries") <> " has non-integer threshold " <> show rawThreshold)
else if catIdx < 0 || catIdx >= nBound - 1 else if nBound < 2
then Left ("Categorical node " <> show i then Left ("Categorical node " <> show i
<> " has out-of-range bitmap slice index " <> show catIdx) <> " requires non-empty cat_boundaries")
else Right () else if catIdx < 0 || catIdx >= nBound - 1
then Left ("Categorical node " <> show i
<> " has out-of-range bitmap slice index " <> show catIdx)
else Right ()
validateEnsemble :: Int -> Ensemble -> Either String () validateEnsemble :: Int -> Ensemble -> Either String ()
validateEnsemble expectedFeatures ens = do validateEnsemble expectedFeatures ens = do

View File

@ -170,4 +170,4 @@ wafMiddleware rsVar app req respond = do
[ ("Content-Type", "text/plain; charset=utf-8") [ ("Content-Type", "text/plain; charset=utf-8")
, (wafResponseHeader, "blocked score=" <> BC.pack (show score)) , (wafResponseHeader, "blocked score=" <> BC.pack (show score))
] ]
"403 Forbidden request blocked by Aenebris WAF" "403 Forbidden - request blocked by Aenebris WAF"