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:
parent
998b5268e0
commit
d9dd59db2a
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue