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
|
||||
author: Carter Perez
|
||||
maintainer: support@certgames.com
|
||||
copyright: 2025 Carter Perez
|
||||
copyright: 2026 AngelaMos
|
||||
category: Network, Security, Web
|
||||
build-type: Simple
|
||||
extra-source-files: README.md
|
||||
|
|
|
|||
|
|
@ -60,9 +60,11 @@ import qualified Data.ByteString.Char8 as BC
|
|||
import qualified Data.ByteString.Lazy as LBS
|
||||
import Data.GeoIP2 (GeoDB, GeoResult(..), AS(..), findGeoData, openGeoDB)
|
||||
import Data.IP (IP(..), fromHostAddress, fromHostAddress6)
|
||||
import Data.List (minimumBy)
|
||||
import Data.Map.Strict (Map)
|
||||
import qualified Data.Map.Strict as Map
|
||||
import Data.Maybe (fromMaybe, isJust)
|
||||
import Data.Ord (comparing)
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import qualified Data.Text.Encoding as TE
|
||||
|
|
@ -95,6 +97,9 @@ defaultGeoSweepIntervalMicros = 60_000_000
|
|||
defaultGeoFlaggedAsns :: [Int]
|
||||
defaultGeoFlaggedAsns = []
|
||||
|
||||
defaultGeoAsnCountCap :: Int
|
||||
defaultGeoAsnCountCap = 200_000
|
||||
|
||||
geoResponseHeaderName :: HeaderName
|
||||
geoResponseHeaderName = "x-aenebris-geo"
|
||||
|
||||
|
|
@ -262,9 +267,20 @@ bumpAsnCounter Geo{..} n now = do
|
|||
| now - awWindowStart w < window ->
|
||||
w { awCount = awCount w + 1 }
|
||||
_ -> 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)
|
||||
|
||||
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{..} count =
|
||||
let threshold = max 1 (gcConcentrationThreshold geoConfig)
|
||||
|
|
|
|||
|
|
@ -274,7 +274,7 @@ robotsResponse cfg =
|
|||
robotsTxtBody :: HoneypotConfig -> ByteString
|
||||
robotsTxtBody HoneypotConfig{..} = BS.concat $
|
||||
[ "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"
|
||||
] <> map disallowLine hpPatterns
|
||||
where
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ module Aenebris.ML.IForest
|
|||
, minSubsampleForNormalization
|
||||
, defaultIForestNumTrees
|
||||
, defaultIForestSubsampleSize
|
||||
, maxIForestDepth
|
||||
) where
|
||||
|
||||
import Data.Vector (Vector)
|
||||
|
|
@ -44,6 +45,12 @@ defaultIForestNumTrees = 100
|
|||
defaultIForestSubsampleSize :: Int
|
||||
defaultIForestSubsampleSize = 256
|
||||
|
||||
maxIForestDepth :: Int
|
||||
maxIForestDepth = 64
|
||||
|
||||
depthBoundLeafSize :: Int
|
||||
depthBoundLeafSize = 1
|
||||
|
||||
data ITree
|
||||
= ITreeLeaf !Int
|
||||
| ITreeSplit !Int !Double !ITree !ITree
|
||||
|
|
@ -77,14 +84,17 @@ averagePathLength !trees !fv =
|
|||
addPath !acc !tree = acc + pathLength tree fv initialDepth
|
||||
|
||||
pathLength :: ITree -> VU.Vector Double -> Int -> Double
|
||||
pathLength !tree !fv !currentDepth = case tree of
|
||||
ITreeLeaf !size ->
|
||||
fromIntegral currentDepth + normalizationConstant size
|
||||
ITreeSplit !featIdx !thr !left !right ->
|
||||
let !fval = fv VU.! featIdx
|
||||
in if fval <= thr
|
||||
then pathLength left fv (currentDepth + 1)
|
||||
else pathLength right fv (currentDepth + 1)
|
||||
pathLength !tree !fv !currentDepth
|
||||
| currentDepth >= maxIForestDepth =
|
||||
fromIntegral currentDepth + normalizationConstant depthBoundLeafSize
|
||||
| otherwise = case tree of
|
||||
ITreeLeaf !size ->
|
||||
fromIntegral currentDepth + normalizationConstant size
|
||||
ITreeSplit !featIdx !thr !left !right ->
|
||||
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 n
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ challengeWireText :: ByteString
|
|||
challengeWireText = "challenge"
|
||||
|
||||
botBlockBody :: LBS.ByteString
|
||||
botBlockBody = "403 Forbidden \x2014 request blocked by Aenebris ML"
|
||||
botBlockBody = "403 Forbidden - request blocked by Aenebris ML"
|
||||
|
||||
challengePageBody :: LBS.ByteString
|
||||
challengePageBody =
|
||||
|
|
|
|||
|
|
@ -329,15 +329,19 @@ validateCategoricalNode
|
|||
:: Int -> Int -> Tree -> Int -> Int -> Int -> Int -> Either String ()
|
||||
validateCategoricalNode featureCount nodeCount t i fIdx lIdx rIdx = do
|
||||
validateSplitNode featureCount nodeCount i fIdx lIdx rIdx
|
||||
let catIdx = floor (treeThreshold t VU.! i) :: Int
|
||||
nBound = VU.length (treeCatBoundaries t)
|
||||
if nBound < 2
|
||||
let rawThreshold = treeThreshold t VU.! i
|
||||
catIdx = floor rawThreshold :: Int
|
||||
nBound = VU.length (treeCatBoundaries t)
|
||||
if fromIntegral catIdx /= rawThreshold
|
||||
then Left ("Categorical node " <> show i
|
||||
<> " requires non-empty cat_boundaries")
|
||||
else if catIdx < 0 || catIdx >= nBound - 1
|
||||
<> " has non-integer threshold " <> show rawThreshold)
|
||||
else if nBound < 2
|
||||
then Left ("Categorical node " <> show i
|
||||
<> " has out-of-range bitmap slice index " <> show catIdx)
|
||||
else Right ()
|
||||
<> " requires non-empty cat_boundaries")
|
||||
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 expectedFeatures ens = do
|
||||
|
|
|
|||
|
|
@ -170,4 +170,4 @@ wafMiddleware rsVar app req respond = do
|
|||
[ ("Content-Type", "text/plain; charset=utf-8")
|
||||
, (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