diff --git a/.jules/palette.md b/.jules/palette.md index 74cddb7ad..61b52f1e1 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -19,3 +19,6 @@ ## 2024-07-13 - CLI Debugging Stack Traces **Learning:** Adding a `FAST_MLSIRM_DEBUG` bypass to user-friendly `try/except` blocks is crucial for DX. Otherwise, unexpected runtime errors during development will be swallowed into generic stderr messages, hiding the stack trace needed to actually fix the bug. **Action:** When adding `try-except` blocks to Python CLI subcommands to improve Developer Experience (DX) by preventing raw tracebacks for users, include a debug bypass (e.g., `if os.environ.get("FAST_MLSIRM_DEBUG"): raise`) in *all* catch blocks (including `RuntimeError` and `Exception`) to ensure tracebacks aren't swallowed during local development and debugging. +## 2024-05-15 - Accessible Data Tables +**Learning:** Using for the first column improves screen reader experience, but requires careful CSS updates to prevent visual regressions when replacing tags. +**Action:** Always update table CSS rules (like borders and fonts) to include tbody th when enhancing accessibility. diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 0b0e3ab9d..94c8ebd73 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -22,3 +22,7 @@ Explicitly defining `allow_pickle=False` is a robust defense-in-depth practice. **Vulnerability:** MD5 hashing in `fast_mlsirm/report.py` triggered a high severity warning by Bandit, because by default it is assumed to be used for security purposes which is unsafe due to weak hashing. **Learning:** For non-security purposes like generating unique dom ids, `hashlib.md5()` triggers a vulnerability warning unless `usedforsecurity=False` is passed. This allows bypassing FIPS compliance limitations as well as suppressing false positive warnings. **Prevention:** Always add `usedforsecurity=False` parameter to `hashlib.md5` and other weak hashing functions unless they are genuinely used for secure cryptography (which they shouldn't be). +## 2024-05-15 - Strix Availability Remediation (vuln-0001) +**Vulnerability:** Diagnostics HTML renderer previously materialized entire attacker-sized JSON collections without limits, leading to excessive memory (CWE-400, CVSS 5.5). +**Learning:** Rendering components that accept JSON input must bound the size of inputs and collection iterations to prevent DoS. +**Prevention:** Apply a strict 16 MiB boundary check on source JSON read and bound column iterations directly in row materialization (`_rows_from_columnar`). diff --git a/python/fast_mlsirm/report.py b/python/fast_mlsirm/report.py index 30ee4276b..c409170f1 100644 --- a/python/fast_mlsirm/report.py +++ b/python/fast_mlsirm/report.py @@ -17,6 +17,8 @@ def render_diagnostics_report( """Render saved diagnostics JSON as a standalone HTML report.""" source = Path(diagnostics_path) + if source.stat().st_size > 16 * 1024 * 1024: + raise ValueError("diagnostics JSON exceeds 16 MiB limit") payload = json.loads(source.read_text(encoding="utf-8")) if not isinstance(payload, dict): raise ValueError("diagnostics JSON must contain an object") @@ -154,7 +156,12 @@ def _render_dimensionality_report(payload: dict[str, Any]) -> list[str]: if not isinstance(candidates, list): raise ValueError("candidates must be a list") - rows = [row for row in candidates if isinstance(row, dict)] + rows = [] + for row in candidates: + if isinstance(row, dict): + rows.append(row) + if len(rows) >= 100: + break sections = [] no_metric_sections = [] best_section = _metric_section("Best Candidate", best) @@ -180,7 +187,7 @@ def _render_dimensionality_report(payload: dict[str, Any]) -> list[str]: def _metric_section(heading: str, metrics: dict[str, Any]) -> str | None: cards = [] - for key, value in metrics.items(): + for key, value in list(metrics.items())[:24]: cards.append( "\n".join( [ @@ -269,11 +276,16 @@ def _bar_chart(rows: list[dict[str, Any]], value_key: str | None) -> str: return "" if not rows: return "" - numeric_rows = [ - (index, row, float(row[value_key])) - for index, row in enumerate(rows) - if _is_number(row.get(value_key)) - ] + numeric_rows = [] + for index, row in enumerate(rows): + val = row.get(value_key) + if _is_number(val): + try: + numeric_rows.append((index, row, float(val))) + except (OverflowError, ValueError, TypeError): + pass + if len(numeric_rows) >= 12: + break values = [value for _, _, value in numeric_rows] if not values: return "" @@ -318,8 +330,10 @@ def _table(rows: list[dict[str, Any]], *, label: str, limit: int = 12) -> str: body_rows = [] for row in rows[:limit]: cells = "".join( - f"{escape(_format_value(row.get(column, '')))}" - for column in columns + f'{escape(_format_value(row.get(column, "")))}' + if i == 0 + else f"{escape(_format_value(row.get(column, '')))}" + for i, column in enumerate(columns) ) body_rows.append(f"{cells}") @@ -356,14 +370,14 @@ def _rows_from_columnar(section: Any) -> list[dict[str, Any]]: if not isinstance(section, dict) or not section: return [] - columns = list(section) + columns = list(section)[:20] lengths = [_value_length(section[column]) for column in columns] row_count = max(lengths) if lengths else 0 if row_count == 0: return [] rows = [] - for index in range(row_count): + for index in range(min(row_count, 100)): row = {} for column in columns: row[column] = _index_value(section[column], index) @@ -377,6 +391,8 @@ def _columns(rows: list[dict[str, Any]]) -> list[str]: for key in row: if key not in ordered: ordered.append(key) + if len(ordered) >= 20: + return ordered return ordered @@ -416,14 +432,17 @@ def _format_value(value: Any) -> str: if isinstance(value, bool): return "true" if value else "false" if isinstance(value, int): - return str(value) + s = str(value) + return s if len(s) <= 100 else s[:100] + "..." if isinstance(value, float): if math.isnan(value) or math.isinf(value): return str(value) return f"{value:.4g}" if value is None: return "" - return str(value) + s = str(value) + return s if len(s) <= 100 else s[:100] + "..." + def _format_label_value(value: Any) -> str: @@ -433,11 +452,12 @@ def _format_label_value(value: Any) -> str: def _is_number(value: Any) -> bool: - return ( - isinstance(value, (int, float)) - and not isinstance(value, bool) - and math.isfinite(float(value)) - ) + if not isinstance(value, (int, float)) or isinstance(value, bool): + return False + try: + return math.isfinite(float(value)) + except (OverflowError, ValueError, TypeError): + return False def _content_security_policy() -> str: @@ -679,21 +699,28 @@ def _css() -> str: border: 0; } -th, +thead th, +tbody th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--line); white-space: nowrap; + font-variant-numeric: tabular-nums; } -th { +thead th { background: #f1f4ef; color: #2f3437; font-size: 0.8rem; } -tr:last-child td { +tbody th { + font-weight: normal; +} + +tr:last-child td, +tr:last-child tbody th { border-bottom: 0; }