diff --git a/app/services/ai_reporter.py b/app/services/ai_reporter.py index 23530e5..96747ae 100644 --- a/app/services/ai_reporter.py +++ b/app/services/ai_reporter.py @@ -1,12 +1,17 @@ import json import logging +import re from app.config import Settings from app.schemas import Finding -from app.services.owasp import risk_grade +from app.services.owasp import explain_risk_rule, get_remediation_card, risk_grade +from app.services.secrets import resolve_anthropic_api_key logger = logging.getLogger(__name__) +CVE_PATTERN = re.compile(r"\bCVE-\d{4}-\d{4,7}\b", re.I) +CITATION_PATTERN = re.compile(r"\[ID:\s*([^\]]+)\]") + def _count_by_risk(findings: list[Finding]) -> dict[str, int]: counts = {"Critical": 0, "High": 0, "Medium": 0, "Low": 0, "Informational": 0} @@ -24,17 +29,41 @@ def friendly_category_label(owasp: str) -> str: return owasp +def sanitize_grounded_report(report_text: str, findings: list[Finding]) -> str: + """Ensure the report cites only valid finding IDs and contains no hallucinated CVE numbers.""" + valid_ids = {f.id for f in findings} + allowed_cves: set[str] = set() + for f in findings: + blob = f"{f.name} {f.description} {f.evidence}" + for m in CVE_PATTERN.findall(blob): + allowed_cves.add(m.upper()) + + def _replace_cve(match: re.Match[str]) -> str: + cve = match.group(0).upper() + return match.group(0) if cve in allowed_cves else "a known security advisory" + + cleaned = CVE_PATTERN.sub(_replace_cve, report_text) + + def _replace_citation(match: re.Match[str]) -> str: + cited_id = match.group(1).strip() + return f"[ID: {cited_id}]" if cited_id in valid_ids else "" + + cleaned = CITATION_PATTERN.sub(_replace_citation, cleaned) + return cleaned + + def generate_template_report(target_url: str, findings: list[Finding]) -> tuple[str, str, str]: - """Fallback report when no AI API key is configured.""" + """Grounded fallback report when no AI API key is configured (no hallucinated CVEs).""" counts = _count_by_risk(findings) grade = risk_grade(counts["Critical"], counts["High"], counts["Medium"]) + score_info = explain_risk_rule(counts["Critical"], counts["High"], counts["Medium"], counts["Low"]) executive = ( f"We checked {target_url} and gave it a safety score of **{grade}** " - f"(A is best, F needs urgent attention). " + f"({score_info['numeric_score']}/100 — A is best, F needs urgent attention). " f"We found {len(findings)} issue(s): " - f"{counts['High']} serious, {counts['Medium']} moderate, {counts['Low']} minor, " - f"and {counts['Informational']} low-priority notes. " + f"{counts['Critical']} critical, {counts['High']} serious, {counts['Medium']} moderate, " + f"{counts['Low']} minor, and {counts['Informational']} low-priority notes. " ) if counts["High"] or counts["Critical"]: executive += ( @@ -50,7 +79,7 @@ def generate_template_report(target_url: str, findings: list[Finding]) -> tuple[ lines = [ "# ShieldScan Security Report\n", f"**Target:** {target_url}\n", - f"**Risk Grade:** {grade}\n", + f"**Risk Grade:** {grade} ({score_info['numeric_score']}/100 — {score_info['explanation']})\n", f"**Total Findings:** {len(findings)}\n", "## Executive Summary\n", executive, @@ -60,18 +89,31 @@ def generate_template_report(target_url: str, findings: list[Finding]) -> tuple[ order = {"Critical": 0, "High": 1, "Medium": 2, "Low": 3, "Informational": 4} sorted_findings = sorted(findings, key=lambda f: order.get(f.risk, 5)) + seen_categories: list[str] = [] for i, f in enumerate(sorted_findings[:20], 1): - lines.append(f"### {i}. [{f.risk}] {f.name}\n") + card = get_remediation_card(f.owasp_category) + if card["code"] not in seen_categories: + seen_categories.append(card["code"]) + lines.append(f"### {i}. [{f.risk}] {f.name} [ID: {f.id}]\n") + lines.append(f"- **Finding ID:** `{f.id}`\n") lines.append(f"- **URL:** {f.url}\n") if f.parameter: lines.append(f"- **Parameter:** {f.parameter}\n") lines.append(f"- **Category:** {friendly_category_label(f.owasp_category)}\n") - lines.append(f"- **What it means:** {f.description or 'Something on your site could be safer.'}\n") - lines.append(f"- **How to fix:** {f.solution or 'Ask your web developer or hosting provider to review this.'}\n") + lines.append(f"- **What it means:** {f.description or card['what_it_means']}\n") + lines.append(f"- **How to fix:** {f.solution or card['how_to_fix']}\n") if f.evidence: lines.append(f"- **Evidence:** `{f.evidence[:200]}`\n") lines.append("\n") + if seen_categories: + lines.append("## Plain-English Remediation Guide by Category\n") + for code in seen_categories: + card = get_remediation_card(code) + lines.append(f"### {card['code']} — {card['title']}\n") + lines.append(f"- **Plain-English Meaning:** {card['what_it_means']}\n") + lines.append(f"- **Recommended Fix:** {card['how_to_fix']}\n\n") + lines.append("## Tips for small business owners\n") lines.append( "1. Fix serious problems within 48 hours if you handle customer data.\n" @@ -81,7 +123,7 @@ def generate_template_report(target_url: str, findings: list[Finding]) -> tuple[ "5. Before launching payments or personal data, consider a professional security review.\n" ) - full_report = "".join(lines) + full_report = sanitize_grounded_report("".join(lines), findings) return executive, grade, full_report @@ -90,13 +132,14 @@ async def generate_ai_report( target_url: str, findings: list[Finding], ) -> tuple[str, str, str]: - if not settings.anthropic_api_key: + api_key = resolve_anthropic_api_key(settings) + if not api_key: return generate_template_report(target_url, findings) try: import anthropic - client = anthropic.Anthropic(api_key=settings.anthropic_api_key) + client = anthropic.Anthropic(api_key=api_key) findings_payload = [f.model_dump() for f in findings] counts = _count_by_risk(findings) grade = risk_grade(counts["Critical"], counts["High"], counts["Medium"]) @@ -109,12 +152,12 @@ async def generate_ai_report( Write a report with these sections: 1. EXECUTIVE SUMMARY (3-4 short sentences, everyday language, no acronyms unless you explain them) -2. TOP PRIORITIES (numbered list, most urgent first, max 5 items — say what to fix and why it matters to the business) -3. DETAILED FINDINGS (for each finding: plain explanation, business impact, simple fix steps) +2. TOP PRIORITIES (numbered list, most urgent first, max 5 items — cite each finding with [ID: ]) +3. DETAILED FINDINGS (for each finding: cite [ID: ], plain explanation, business impact, simple fix steps) 4. NEXT STEPS (short checklist the owner can give their developer or host) Rules: -- Only discuss findings in the JSON. Do NOT invent problems. +- Only discuss findings in the JSON and cite their exact [ID: ]. Do NOT invent problems or hallucinate CVE numbers. - Write like you're explaining to a smart non-technical person. - Give practical fixes, not vague advice. - Mention actual page URLs from the data.""" @@ -124,7 +167,8 @@ async def generate_ai_report( max_tokens=4000, messages=[{"role": "user", "content": prompt}], ) - full_report = message.content[0].text + raw_report = message.content[0].text + full_report = sanitize_grounded_report(raw_report, findings) executive = full_report.split("TOP PRIORITIES")[0].replace("EXECUTIVE SUMMARY", "").strip() if len(executive) > 600: executive = executive[:600] + "..." diff --git a/app/services/owasp.py b/app/services/owasp.py index 4355bc5..c026288 100644 --- a/app/services/owasp.py +++ b/app/services/owasp.py @@ -58,6 +58,14 @@ def classify_owasp(cwe: str = "", alert_name: str = "") -> str: def risk_grade(critical: int, high: int, medium: int) -> str: + """Calculate the letter safety grade (A-F) from severity counts. + + Algorithm: + - Critical > 2 -> F; Critical >= 1 -> D + - High > 3 -> D; High >= 1 -> C + - Medium > 5 -> C; Medium >= 1 -> B + - 0 Critical, 0 High, 0 Medium -> A + """ if critical > 0: return "F" if critical > 2 else "D" if high > 3: @@ -71,6 +79,94 @@ def risk_grade(critical: int, high: int, medium: int) -> str: return "A" +def explain_risk_rule(critical: int, high: int, medium: int, low: int = 0) -> dict[str, str | int]: + """Return the letter grade, numeric safety score (0-100), and plain-English explanation.""" + grade = risk_grade(critical, high, medium) + penalty = (critical * 25) + (high * 12) + (medium * 4) + (low * 1) + numeric_score = max(0, 100 - penalty) + reasons = { + "A": "No critical, high, or medium risks detected.", + "B": "Only moderate configuration issues found (1-5 medium findings, 0 high/critical).", + "C": "Serious or numerous moderate issues require developer attention (1-3 high or >5 medium).", + "D": "High-risk vulnerabilities present (1-2 critical or >3 high findings).", + "F": "Urgent intervention required (>2 critical vulnerabilities).", + } + return { + "grade": grade, + "numeric_score": numeric_score, + "explanation": reasons[grade], + } + + +OWASP_REMEDIATION_CARDS: dict[str, dict[str, str]] = { + "A01:2021": { + "code": "A01:2021", + "title": "Access Control & Form Protection", + "what_it_means": "Pages or actions that should be private can be opened or triggered without proper checks.", + "how_to_fix": "Require login on admin/user pages, verify user ownership for every record ID, and add CSRF tokens to all POST forms.", + }, + "A02:2021": { + "code": "A02:2021", + "title": "Data & Connection Encryption", + "what_it_means": "Passwords or cookies may travel over unencrypted connections where attackers on Wi-Fi can read them.", + "how_to_fix": "Enable HTTPS across your entire website, redirect HTTP to HTTPS, and mark login cookies with Secure and HttpOnly.", + }, + "A03:2021": { + "code": "A03:2021", + "title": "Input & Database Injection", + "what_it_means": "Search boxes, URL parameters, or forms accept untrusted commands that can read your database or run scripts.", + "how_to_fix": "Use prepared/parameterised database queries and escape all user text before displaying it on a page.", + }, + "A04:2021": { + "code": "A04:2021", + "title": "Safe Workflow Design", + "what_it_means": "Important business forms (like password resets or public submissions) lack verification steps or spam protection.", + "how_to_fix": "Require one-time reset tokens for password recovery and add CAPTCHA or rate limits to public forms.", + }, + "A05:2021": { + "code": "A05:2021", + "title": "Website Security Settings", + "what_it_means": "Browser safety headers are missing or internal folders/files (.env, backups) are publicly visible.", + "how_to_fix": "Add standard security headers (CSP, HSTS, X-Frame-Options), disable directory listing, and block access to dotfiles and backups.", + }, + "A06:2021": { + "code": "A06:2021", + "title": "Outdated Website Libraries", + "what_it_means": "Your website loads older JavaScript or framework versions that have publicly known security bugs.", + "how_to_fix": "Update jQuery, Bootstrap, WordPress plugins, and frontend packages to their current stable versions.", + }, + "A07:2021": { + "code": "A07:2021", + "title": "Login & Account Protection", + "what_it_means": "Login forms allow unlimited password guessing or expose credentials in browser history.", + "how_to_fix": "Serve login forms over HTTPS POST only, limit failed login attempts, and enable two-factor authentication for admins.", + }, + "A08:2021": { + "code": "A08:2021", + "title": "Software & Script Integrity", + "what_it_means": "External scripts loaded from third-party CDNs are not verified with cryptographic hashes.", + "how_to_fix": "Add Subresource Integrity (integrity='sha384-...') attributes to third-party script tags.", + }, + "A09:2021": { + "code": "A09:2021", + "title": "Error Messages & Monitoring", + "what_it_means": "Detailed technical error messages or stack traces are shown to visitors when something breaks.", + "how_to_fix": "Show friendly generic error pages to visitors and keep technical stack traces in private server logs only.", + }, + "A10:2021": { + "code": "A10:2021", + "title": "Outbound Server Requests (SSRF)", + "what_it_means": "URL parameters accept external web addresses that could trick your server into fetching internal resources.", + "how_to_fix": "Allowlist trusted domains and block requests to internal/private IP addresses (127.0.0.1, 169.254.x.x).", + }, +} + + +def get_remediation_card(owasp_category: str) -> dict[str, str]: + code = (owasp_category or "").split(" ")[0].strip() + return dict(OWASP_REMEDIATION_CARDS.get(code, OWASP_REMEDIATION_CARDS["A05:2021"])) + + def progress_for_status(status: str) -> int: return { "queued": 5, diff --git a/app/services/secrets.py b/app/services/secrets.py index 1f10bdf..17aa4fd 100644 --- a/app/services/secrets.py +++ b/app/services/secrets.py @@ -1,7 +1,11 @@ -"""Startup secrets validation (Drips Wave #29 pattern).""" +"""Startup secrets validation and passphrase-encrypted API key storage (Drips Wave #29 pattern).""" +import base64 +import hashlib +import hmac import logging import os +import secrets as std_secrets from app.config import Settings @@ -9,6 +13,88 @@ DEFAULT_ZAP_KEY = "changeme" INSECURE_ZAP_KEYS = frozenset({"changeme", "zap", "password", "admin", ""}) +ENCRYPTED_PREFIX = "enc:v1:" +PBKDF2_ITERATIONS = 100_000 + + +def is_encrypted_secret(value: str) -> bool: + """Return True if the secret value uses the enc:v1: encrypted envelope.""" + return bool(value and value.strip().startswith(ENCRYPTED_PREFIX)) + + +def _derive_keys(passphrase: str, salt: bytes) -> tuple[bytes, bytes]: + if not passphrase: + raise ValueError("Passphrase must not be empty") + key_material = hashlib.pbkdf2_hmac( + "sha256", + passphrase.encode("utf-8"), + salt, + PBKDF2_ITERATIONS, + dklen=64, + ) + return key_material[:32], key_material[32:] + + +def _keystream_xor(data: bytes, enc_key: bytes, salt: bytes) -> bytes: + out = bytearray(len(data)) + block_idx = 0 + offset = 0 + while offset < len(data): + block = hashlib.sha256(enc_key + salt + block_idx.to_bytes(4, "big")).digest() + chunk = data[offset : offset + 32] + for i, b in enumerate(chunk): + out[offset + i] = b ^ block[i] + offset += 32 + block_idx += 1 + return bytes(out) + + +def encrypt_secret(plaintext: str, passphrase: str) -> str: + """Encrypt an API key using PBKDF2-HMAC-SHA256 + authenticated keystream (enc:v1:...).""" + if not plaintext: + raise ValueError("Plaintext secret must not be empty") + salt = std_secrets.token_bytes(16) + enc_key, mac_key = _derive_keys(passphrase, salt) + pt_bytes = plaintext.encode("utf-8") + ct_bytes = _keystream_xor(pt_bytes, enc_key, salt) + mac = hmac.new(mac_key, salt + ct_bytes, hashlib.sha256).digest() + salt_b64 = base64.urlsafe_b64encode(salt).decode("ascii") + ct_b64 = base64.urlsafe_b64encode(ct_bytes).decode("ascii") + mac_b64 = base64.urlsafe_b64encode(mac).decode("ascii") + return f"{ENCRYPTED_PREFIX}{salt_b64}:{ct_b64}:{mac_b64}" + + +def decrypt_secret(token: str, passphrase: str) -> str: + """Decrypt an enc:v1:... encrypted secret and verify its HMAC integrity tag.""" + if not is_encrypted_secret(token): + raise ValueError("Secret does not have enc:v1: prefix") + body = token.strip()[len(ENCRYPTED_PREFIX) :] + parts = body.split(":") + if len(parts) != 3: + raise ValueError("Malformed enc:v1: secret envelope") + salt = base64.urlsafe_b64decode(parts[0].encode("ascii")) + ct_bytes = base64.urlsafe_b64decode(parts[1].encode("ascii")) + expected_mac = base64.urlsafe_b64decode(parts[2].encode("ascii")) + enc_key, mac_key = _derive_keys(passphrase, salt) + actual_mac = hmac.new(mac_key, salt + ct_bytes, hashlib.sha256).digest() + if not hmac.compare_digest(actual_mac, expected_mac): + raise ValueError("Invalid passphrase or corrupted encrypted secret") + pt_bytes = _keystream_xor(ct_bytes, enc_key, salt) + return pt_bytes.decode("utf-8") + + +def resolve_anthropic_api_key(settings: Settings, passphrase: str | None = None) -> str: + """Resolve ANTHROPIC_API_KEY, decrypting enc:v1: tokens when a passphrase is provided.""" + raw = (settings.anthropic_api_key or "").strip() + if not raw: + return "" + if not is_encrypted_secret(raw): + return raw + effective_passphrase = passphrase if passphrase is not None else os.getenv("SHIELDSCAN_SECRET_PASSPHRASE", "") + if not effective_passphrase: + logger.warning("[secrets] ANTHROPIC_API_KEY is encrypted (enc:v1:) but SHIELDSCAN_SECRET_PASSPHRASE is not set") + return "" + return decrypt_secret(raw, effective_passphrase) def validate_settings(settings: Settings) -> list[str]: @@ -26,9 +112,19 @@ def validate_settings(settings: Settings) -> list[str]: if settings.scanner_mode == "zap" and not settings.zap_api_url: warnings.append("WARNING: SCANNER_MODE=zap but ZAP_API_URL is empty") - if env == "production" and settings.anthropic_api_key.startswith("sk-ant-"): - if len(settings.anthropic_api_key) < 20: - warnings.append("WARNING: ANTHROPIC_API_KEY appears truncated") + if settings.anthropic_api_key: + if is_encrypted_secret(settings.anthropic_api_key): + if not os.getenv("SHIELDSCAN_SECRET_PASSPHRASE"): + warnings.append( + "WARNING: ANTHROPIC_API_KEY is encrypted (enc:v1:) but SHIELDSCAN_SECRET_PASSPHRASE is missing" + ) + elif env == "production": + warnings.append( + "WARNING: ANTHROPIC_API_KEY is stored in plaintext without encryption in production — " + "use passphrase-encrypted storage (enc:v1:...)" + ) + if settings.anthropic_api_key.startswith("sk-ant-") and len(settings.anthropic_api_key) < 20: + warnings.append("WARNING: ANTHROPIC_API_KEY appears truncated") for w in warnings: logger.warning("[secrets] %s", w) diff --git a/docs/safety_score_and_chapter5.md b/docs/safety_score_and_chapter5.md new file mode 100644 index 0000000..102fc39 --- /dev/null +++ b/docs/safety_score_and_chapter5.md @@ -0,0 +1,74 @@ +# Safety Score Algorithm, Encrypted Key Storage & Viva Demo Guide (Phase 4) + +**Project:** ShieldScan — AI-Assisted Web Application Vulnerability Assessment Tool for Small Businesses +**References:** PRD §4 · FUT Minna Thesis Chapter 5 · Presentation Slides 9–10 + +--- + +## 1. Documented Safety Score Algorithm (A–F Grading) + +ShieldScan computes both a **letter grade (`A`–`F`)** (`risk_grade` in `app/services/owasp.py`) and a **numeric safety score (`0`–`100`)** (`explain_risk_rule`) so non-specialist SMB owners immediately understand urgency while technical reviewers can inspect deterministic thresholds. + +### 1.1 Deterministic Letter Grade Decision Table + +Evaluated in strict priority order from highest severity to lowest: + +| Condition | Grade | Plain-English Meaning | +|---|---|---| +| `Critical > 2` | **F** | Urgent intervention required (> 2 critical vulnerabilities) | +| `Critical >= 1` (1–2 Critical) | **D** | High-risk vulnerabilities present | +| `High > 3` | **D** | Multiple serious vulnerabilities present | +| `High >= 1` (1–3 High) | **C** | Serious issues require developer attention | +| `Medium > 5` | **C** | Numerous moderate configuration issues require attention | +| `Medium >= 1` (1–5 Medium) | **B** | Only moderate configuration issues found (0 High/Critical) | +| `Critical == 0` and `High == 0` and `Medium == 0` | **A** | No critical, high, or medium risks detected | + +### 1.2 Numeric Penalty Formula (`0`–`100`) + +```text +penalty = (25 × Critical) + (12 × High) + (4 × Medium) + (1 × Low) +numeric_score = max(0, 100 - penalty) +``` + +--- + +## 2. Grounded AI Reporting & Anti-Hallucination Quality Gate + +To guarantee zero hallucinated vulnerabilities or fabricated CVE numbers (`app/services/ai_reporter.py`): + +1. **Mandatory Finding ID Citations (`[ID: ]`)**: + - Every finding in both the Claude AI prompt and the deterministic template fallback is cited by its exact `finding.id` (e.g. `[ID: sqli-fuzz-id]`). +2. **Post-Generation Grounding Sanitizer (`sanitize_grounded_report`)**: + - Verifies every `[ID: ...]` citation against the scan's actual `findings` list and strips any citation not present in the scan results. + - Scans the generated report for `CVE-YYYY-NNNN+` identifiers and replaces any CVE not present in the underlying finding evidence/description with `"a known security advisory"`. +3. **Per-OWASP-Category Plain-English Remediation Cards**: + - Both the web dashboard (`#remediation-cards` in `static/index.html` & `static/js/app.js`) and the Markdown/HTML report render plain-English "What it means" and "How to fix" cards for `A01:2021` through `A10:2021`. + +--- + +## 3. Passphrase-Encrypted API Key Storage (`app/services/secrets.py`) + +Aligned with Stellar Wave #29 secure key storage: + +- `encrypt_secret(plaintext, passphrase)` derives a 256-bit encryption key and a 256-bit MAC key using **PBKDF2-HMAC-SHA256** (`100,000` iterations, 16-byte random salt) and produces an authenticated `enc:v1:::` token. +- `decrypt_secret(token, passphrase)` verifies the HMAC-SHA256 tag in constant time (`hmac.compare_digest`) before decrypting. +- `validate_settings(settings)` logs a warning when `SHIELDSCAN_ENV=production` and `ANTHROPIC_API_KEY` is stored in plaintext without `enc:v1:` encryption. + +--- + +## 4. Presentation Slides 9–10 & Under-5-Minute Viva Demo Script + +### Slide 9 — Live Dashboard & Safety Score Breakdown +- **Visual:** ShieldScan Results Card displaying Grade Badge (`D` / `F` on DVWA Low), Severity Counters (`Critical`, `High`, `Medium`, `Low`), OWASP Category Breakdown bars, and Plain-English Fix Guide Cards (`#remediation-cards`). +- **Talking Point:** *"Every technical finding from the 5-phase engine is paired with a plain-English remediation card so a small business owner knows what to hand their web developer."* + +### Slide 10 — Grounded AI Report & Export Verification +- **Visual:** Full Report & Fix Guide panel showing `[ID: ...]` grounded citations alongside exported HTML/Markdown reports. +- **Talking Point:** *"Reports cite only verified scan finding IDs and pass through `sanitize_grounded_report` so neither the AI model nor the template fallback can hallucinate unverified CVEs."* + +### < 5-Minute Viva Demo Checklist +1. **0:00–0:45** — Verify lab containers: `./start-lab.sh full && docker compose ps` +2. **0:45–1:15** — Start ShieldScan (`./start.sh`) and open `http://127.0.0.1:8000` +3. **1:15–2:30** — Click **DVWA** preset (`http://127.0.0.1:4280`), check authorisation box, click **Run security scan** +4. **2:30–3:45** — Walk through Safety Score Grade, OWASP Category Remediation Cards, and per-finding "How to fix" column +5. **3:45–4:30** — Show Grounded Report (`[ID: ...]`) and click **Export HTML Report** / **Download Markdown** diff --git a/static/css/style.css b/static/css/style.css index 96ae842..7cf4997 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -137,10 +137,50 @@ main { max-width: 1200px; margin: 0 auto; padding: 2rem; } margin-bottom: 0.65rem; } -.owasp-section { +.owasp-section, .remediation-section { margin: 1rem 0 1.25rem; } +.remediation-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 0.75rem; + margin-top: 0.5rem; +} + +.remediation-card { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.9rem 1rem; + font-size: 0.82rem; +} + +.remediation-card-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.4rem; +} + +.remediation-badge { + font-size: 0.72rem; + color: #93c5fd; + background: rgba(59, 130, 246, 0.12); + padding: 0.12rem 0.45rem; + border-radius: 999px; +} + +.remediation-what { + color: var(--muted); + margin-bottom: 0.35rem; +} + +.remediation-fix { + color: var(--text); +} + .scan-card { background: var(--surface); border: 1px solid var(--border); diff --git a/static/index.html b/static/index.html index 1811ea8..994ea17 100644 --- a/static/index.html +++ b/static/index.html @@ -98,6 +98,11 @@

What types of problems were found

+
+

Plain-English Fix Guide by Category

+
+
+

Summary

diff --git a/static/js/app.js b/static/js/app.js index 9793380..2194d99 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -25,6 +25,7 @@ const els = { statMedium: document.getElementById('stat-medium'), statLow: document.getElementById('stat-low'), owaspBreakdown: document.getElementById('owasp-breakdown'), + remediationCards: document.getElementById('remediation-cards'), history: document.getElementById('history-list'), exportHtml: document.getElementById('export-html'), exportMd: document.getElementById('export-md'), @@ -170,6 +171,90 @@ function countByRisk(findings) { return counts; } +const REMEDIATION_CARDS = { + 'A01:2021': { + title: 'Access Control & Form Protection', + what: 'Private pages or state-changing forms can be triggered without proper authorisation.', + fix: 'Require login on admin/user pages, verify user ownership for every record ID, and add CSRF tokens to POST forms.', + }, + 'A02:2021': { + title: 'Data & Connection Encryption', + what: 'Passwords or session cookies may travel over unencrypted HTTP connections.', + fix: 'Enable HTTPS site-wide, redirect HTTP to HTTPS, and set Secure + HttpOnly flags on login cookies.', + }, + 'A03:2021': { + title: 'Input & Database Injection', + what: 'Search boxes, URL parameters, or forms accept untrusted commands or scripts.', + fix: 'Use parameterised database queries and escape all user input before rendering it in HTML.', + }, + 'A04:2021': { + title: 'Safe Workflow Design', + what: 'Password reset or public submission forms lack verification tokens or spam limits.', + fix: 'Require one-time reset tokens for password recovery and add CAPTCHA/rate limits to public forms.', + }, + 'A05:2021': { + title: 'Website Security Settings', + what: 'Browser security headers are missing or internal folders/files (.env, backups) are exposed.', + fix: 'Add CSP, HSTS, and X-Frame-Options headers, disable directory listing, and block dotfiles.', + }, + 'A06:2021': { + title: 'Outdated Website Libraries', + what: 'Older JavaScript or CMS plugin versions with known vulnerabilities are loaded.', + fix: 'Upgrade jQuery, Bootstrap, and CMS plugins to their latest stable releases.', + }, + 'A07:2021': { + title: 'Login & Account Protection', + what: 'Login forms allow unlimited password guessing or expose credentials in URLs.', + fix: 'Submit login forms via HTTPS POST only and enforce rate limiting / account lockout.', + }, + 'A08:2021': { + title: 'Software & Script Integrity', + what: 'Third-party scripts are loaded without cryptographic integrity hashes.', + fix: 'Add Subresource Integrity (integrity="sha384-...") attributes to external script tags.', + }, + 'A09:2021': { + title: 'Error Messages & Monitoring', + what: 'Detailed technical stack traces or database errors are visible to site visitors.', + fix: 'Display friendly generic error pages to visitors and log technical details server-side only.', + }, + 'A10:2021': { + title: 'Outbound Server Requests (SSRF)', + what: 'URL-accepting parameters could trick your server into fetching internal network resources.', + fix: 'Allowlist trusted destination domains and block requests to internal/private IP addresses.', + }, +}; + +function getCardForCategory(owasp) { + const code = (owasp || '').split(' ')[0].trim(); + return REMEDIATION_CARDS[code] || REMEDIATION_CARDS['A05:2021']; +} + +function renderRemediationCards(findings) { + if (!els.remediationCards) return; + const seen = new Map(); + findings.forEach(f => { + const code = (f.owasp_category || 'A05:2021').split(' ')[0].trim() || 'A05:2021'; + const card = getCardForCategory(code); + if (!seen.has(code)) { + seen.set(code, { code, count: 0, ...card }); + } + seen.get(code).count += 1; + }); + + const cards = Array.from(seen.values()); + els.remediationCards.innerHTML = cards.length + ? cards.map(c => ` +
+
+ ${esc(c.title)} + ${esc(c.code)} · ${c.count} +
+

${esc(c.what)}

+

How to fix: ${esc(c.fix)}

+
`).join('') + : '

No remediation actions needed.

'; +} + function renderOwaspBreakdown(findings) { const buckets = {}; findings.forEach(f => { @@ -212,13 +297,14 @@ function renderFindings(findings) { filtered.forEach(f => { const tr = document.createElement('tr'); + const plainFix = f.solution || getCardForCategory(f.owasp_category).fix; tr.innerHTML = ` ${esc(f.risk)} ${esc(f.name)} ${esc(f.url)} ${esc(displayParameter(f))} ${esc(truncate(f.evidence || f.description || '—', 120))} - ${esc(f.solution || f.description || '')} + ${esc(plainFix)} `; els.findingsBody.appendChild(tr); }); @@ -264,6 +350,7 @@ async function loadResults(id) { activeFilter = 'all'; document.querySelectorAll('.filter-chip').forEach(c => c.classList.toggle('active', c.dataset.filter === 'all')); renderOwaspBreakdown(allFindings); + renderRemediationCards(allFindings); renderFindings(allFindings); els.report.textContent = data.ai_report || ''; } diff --git a/tests/test_ai_reporting_phase4.py b/tests/test_ai_reporting_phase4.py new file mode 100644 index 0000000..bc3607f --- /dev/null +++ b/tests/test_ai_reporting_phase4.py @@ -0,0 +1,150 @@ +"""Phase 4 tests: grounded AI reporting, anti-hallucination filter, OWASP remediation cards, safety score algorithm, and passphrase-encrypted secrets.""" + +import re + +import pytest +from fastapi.testclient import TestClient + +from app.config import Settings +from app.main import app +from app.schemas import Finding +from app.services.ai_reporter import ( + CITATION_PATTERN, + CVE_PATTERN, + generate_template_report, + sanitize_grounded_report, +) +from app.services.owasp import ( + OWASP_REMEDIATION_CARDS, + explain_risk_rule, + get_remediation_card, + risk_grade, +) +from app.services.secrets import ( + decrypt_secret, + encrypt_secret, + is_encrypted_secret, + resolve_anthropic_api_key, + validate_settings, +) + +client = TestClient(app) + + +def _sample_findings() -> list[Finding]: + return [ + Finding( + id="sqli-fuzz-id", + name="SQL Injection", + risk="High", + confidence="Firm", + url="http://127.0.0.1:4280/vulnerabilities/sqli/", + parameter="id", + evidence="SQL syntax error near MySQL", + description="Parameter 'id' triggers database error messages.", + solution="Use parameterised queries.", + cwe="89", + owasp_category="A03:2021 — Injection", + source="owasp-probe", + ), + Finding( + id="header-content-security-policy", + name="Missing Content-Security-Policy Header", + risk="Medium", + confidence="High", + url="http://127.0.0.1:4280/", + parameter="Site security settings", + evidence="Response headers missing content-security-policy", + description="CSP helps prevent XSS.", + solution="Add a Content-Security-Policy header.", + owasp_category="A05:2021 — Security Misconfiguration", + source="passive", + ), + ] + + +def test_template_report_cites_only_present_findings_and_no_hallucinated_cves(): + findings = _sample_findings() + executive, grade, report = generate_template_report("http://127.0.0.1:4280", findings) + + assert grade == "C" + assert "safety score of **C**" in executive + cited_ids = {m.strip() for m in CITATION_PATTERN.findall(report)} + valid_ids = {f.id for f in findings} + assert cited_ids == valid_ids + assert CVE_PATTERN.findall(report) == [] + assert "Plain-English Remediation Guide by Category" in report + + +def test_sanitize_grounded_report_strips_hallucinated_ids_and_cves(): + findings = _sample_findings() + raw_ai_output = ( + "1. Fix SQL Injection [ID: sqli-fuzz-id] and phantom bug [ID: hallucinated-id-999]. " + "This relates to CVE-2099-12345 which was never in the scan." + ) + cleaned = sanitize_grounded_report(raw_ai_output, findings) + assert "[ID: sqli-fuzz-id]" in cleaned + assert "hallucinated-id-999" not in cleaned + assert "CVE-2099-12345" not in cleaned + assert "a known security advisory" in cleaned + + +def test_owasp_remediation_cards_and_dashboard_ux(): + assert len(OWASP_REMEDIATION_CARDS) == 10 + card_a03 = get_remediation_card("A03:2021 — Injection") + assert card_a03["code"] == "A03:2021" + assert "parameterised" in card_a03["how_to_fix"].lower() + + resp = client.get("/") + assert resp.status_code == 200 + assert 'id="remediation-cards"' in resp.text + assert "Plain-English Fix Guide by Category" in resp.text + + +def test_safety_score_algorithm_grades_a_through_f(): + assert risk_grade(0, 0, 0) == "A" + assert risk_grade(0, 0, 2) == "B" + assert risk_grade(0, 1, 0) == "C" + assert risk_grade(0, 0, 6) == "C" + assert risk_grade(1, 0, 0) == "D" + assert risk_grade(0, 4, 0) == "D" + assert risk_grade(3, 0, 0) == "F" + + explained = explain_risk_rule(1, 2, 1, 0) + assert explained["grade"] == "D" + assert explained["numeric_score"] == 100 - (25 + 24 + 4) + assert re.search(r"High-risk vulnerabilities", str(explained["explanation"])) + + +def test_passphrase_encrypted_api_key_storage_and_production_warning(monkeypatch): + plaintext_key = "sk-ant-api03-test-secret-key-value-123456" + passphrase = "correct-horse-battery-staple" + + encrypted = encrypt_secret(plaintext_key, passphrase) + assert is_encrypted_secret(encrypted) + assert plaintext_key not in encrypted + assert decrypt_secret(encrypted, passphrase) == plaintext_key + + with pytest.raises(ValueError, match="Invalid passphrase"): + decrypt_secret(encrypted, "wrong-passphrase") + + settings_enc = Settings(anthropic_api_key=encrypted) + assert resolve_anthropic_api_key(settings_enc, passphrase=passphrase) == plaintext_key + + # Production warning when ANTHROPIC_API_KEY is stored in plaintext without encryption + monkeypatch.setenv("SHIELDSCAN_ENV", "production") + settings_plain = Settings( + zap_api_key="strong-prod-zap-key-999", + anthropic_api_key=plaintext_key, + ) + warnings = validate_settings(settings_plain) + assert any("stored in plaintext without encryption" in w for w in warnings) + + # Encrypted key with passphrase set in production produces no plaintext warning + monkeypatch.setenv("SHIELDSCAN_SECRET_PASSPHRASE", passphrase) + settings_secure = Settings( + zap_api_key="strong-prod-zap-key-999", + anthropic_api_key=encrypted, + ) + warnings_secure = validate_settings(settings_secure) + assert not any("stored in plaintext" in w for w in warnings_secure)