diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..32b7fe3 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include src/nod/defaults/*.yaml +include README.md +include LICENSE diff --git a/defaults/rules-security-baseline.yaml b/defaults/rules-security-baseline.yaml index 4c62862..23c2768 100644 --- a/defaults/rules-security-baseline.yaml +++ b/defaults/rules-security-baseline.yaml @@ -1,49 +1,101 @@ +# nod Compliance Pack: Security Baseline +# Version: 1.2 (Hardened Regex for v2.1.0) +# Focus: Foundational cybersecurity hygiene for any AI project. + profiles: security_baseline: badge_label: "Security Hardened" + requirements: - - id: "Data Retention Policy" + # Data Protection + - id: "#+.*Data Retention" + label: "Data Retention Policy" tags: ["Privacy", "Data-Protection"] severity: "MEDIUM" remediation: "Define how long training inputs and inference outputs are stored." - - id: "Encryption at Rest" - tags: ["Security", "Encryption", "Storage"] + + - id: "#+.*Encryption at Rest" + label: "Encryption (Rest)" + control_id: "SC-28" + tags: ["Security", "Storage"] severity: "HIGH" remediation: "Must explicitly state encryption protocols for stored data (e.g., AES-256)." - - id: "Encryption in Transit" - tags: ["Security", "Encryption", "Network"] + + - id: "#+.*Encryption in Transit" + label: "Encryption (Transit)" + control_id: "SC-8" + tags: ["Security", "Network"] severity: "HIGH" remediation: "Must explicitly state encryption protocols for data in motion (e.g., TLS 1.3)." - - id: "Authentication Mechanisms" + + # Identity & Access Management (IAM) + - id: "#+.*Authentication" + label: "Authentication" control_id: "AC-1" severity: "HIGH" remediation: "Define how users/systems are authenticated (e.g., MFA, SSO)." - - id: "Authorization Policy" + + - id: "#+.*Authorization" + label: "Authorization" control_id: "AC-2" severity: "HIGH" remediation: "Define permission models (e.g., RBAC, Least Privilege)." - - id: "Secrets Management" - tags: ["Security", "Secrets"] + + - id: "#+.*Secrets Management" + label: "Secrets Management" + control_id: "IA-5" severity: "CRITICAL" remediation: "Define how secrets (API keys) are managed, ensuring they are not hardcoded." - - id: "Audit Logging" + + # Logging & Availability + - id: "#+.*Audit Log" + label: "Audit Logging" control_id: "AU-2" severity: "HIGH" remediation: "Ensure security-critical events (login failures, sensitive access) are logged centrally." - - id: "Rate Limiting" - tags: ["Security", "Availability"] + + - id: "#+.*Rate Limit" + label: "Rate Limiting" + control_id: "SC-5" severity: "MEDIUM" remediation: "Define rate limits to prevent DoS attacks." - - id: "Energy Consumption" + + # Sustainability + - id: "#+.*Energy Consumption" + label: "Sustainability" tags: ["Sustainability", "Green-AI"] severity: "LOW" remediation: "Sustainability: Estimate energy consumption of training/inference." + red_flags: + # Credential Leakage (High Fidelity) + - pattern: "(?i)(password|secret|key|token)\\s*=\\s*['\"][a-zA-Z0-9_\\-]{8,}['\"]" + label: "Generic Secret Assignment" + tags: ["Security", "Secrets"] + severity: "CRITICAL" + remediation: "Potential hardcoded secret detected. Use environment variables or a vault." + + - pattern: "(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}" + label: "AWS Access Key" + tags: ["Security", "Secrets"] + severity: "CRITICAL" + remediation: "AWS Access Key ID detected. Revoke immediately." + + - pattern: "-----BEGIN (RSA|EC|DSA|OPENSSH|PRIVATE)? ?KEY-----" + label: "Private Key Block" + tags: ["Security", "Secrets"] + severity: "CRITICAL" + remediation: "Private Key block detected. Never commit keys to version control." + + - pattern: "sk-[a-zA-Z0-9]{32,}" + label: "OpenAI Key" + tags: ["Security", "Secrets"] + severity: "CRITICAL" + remediation: "OpenAI Secret Key detected." + + # Anti-Patterns - pattern: "disable auth" + label: "Disabled Auth" tags: ["Security", "Anti-Pattern"] severity: "CRITICAL" remediation: "Security Anti-Pattern: Do not disable authentication mechanisms in spec." - - pattern: "hardcoded credential" - tags: ["Security", "Anti-Pattern"] - severity: "CRITICAL" - remediation: "Security Anti-Pattern: Never reference hardcoded secrets or keys." diff --git a/pyproject.toml b/pyproject.toml index 9654ca2..44a1a12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nod-linter" -version = "2.0.0" +version = "2.1.0" description = "A compliance-as-code gatekeeper for AI specifications." readme = "README.md" authors = [ @@ -32,3 +32,6 @@ nod = "nod.cli:main" [tool.setuptools.packages.find] where = ["src"] + +[tool.setuptools.package-data] +nod = ["defaults/*.yaml"] diff --git a/src/nod/__init__.py b/src/nod/__init__.py index 49bdc93..a7095d2 100644 --- a/src/nod/__init__.py +++ b/src/nod/__init__.py @@ -2,4 +2,4 @@ nod: AI Spec Compliance Gatekeeper. """ -__version__ = "2.0.0" +__version__ = "2.1.0" diff --git a/src/nod/cli.py b/src/nod/cli.py index e563b22..531d878 100644 --- a/src/nod/cli.py +++ b/src/nod/cli.py @@ -8,6 +8,7 @@ from .reporters import gen_sarif, gen_report from .security import sign_attestation, freeze, verify from .utils import Colors, colorize +from . import __version__ def main(): parser = argparse.ArgumentParser(description="nod: AI Spec Compliance") @@ -22,12 +23,14 @@ def main(): parser.add_argument("--min-severity", default="HIGH", choices=["MEDIUM", "HIGH", "CRITICAL"]) parser.add_argument("--output", choices=["text", "json", "sarif", "compliance"], default="text") parser.add_argument("--save-to") + parser.add_argument("--version", action="version", version=f"nod v{__version__}") + parser.add_argument("--quiet", "-q", action="store_true", help="Suppress non-error output") args = parser.parse_args() default_rules = ["defaults"] if os.path.isdir("defaults") else ["rules.yaml"] sources = args.rules if args.rules else default_rules - # Init config + # Init config (Quietly unless error) config = load_rules(sources) policy_version = config.get("version", "unknown") ignored = load_ignore(".nodignore") @@ -44,7 +47,8 @@ def main(): sys.exit(1) with open(args.path, "w", encoding="utf-8") as f: f.write(template) - print(f"✅ Generated: {args.path}") + if not args.quiet: + print(f"✅ Generated: {args.path}") else: print(template) sys.exit(0) @@ -61,11 +65,15 @@ def main(): if args.freeze: freeze(policy_version, scanner.attestation) + if not args.quiet: + print(f"✅ Baseline frozen to nod.lock") sys.exit(0) if args.verify: if not verify(scanner.attestation): sys.exit(1) + if not args.quiet: + print("✅ Verification Passed: No drift.") sys.exit(0) if args.fix: @@ -82,38 +90,58 @@ def main(): elif args.output == "compliance": output_content = gen_report(scanner.attestation) else: - summary = [f"\n--- nod Summary ---\nTarget: {args.path}\nMax Sev: {max_sev_label}"] - if scanner.attestation.get("signed"): - summary.append(f"{colorize('🔒 Signed', Colors.GREEN)}") + # Text Output + summary = [] + if not args.quiet: + summary.append(f"\n--- nod Summary ---\nTarget: {args.path}\nMax Sev: {max_sev_label}") + if scanner.attestation.get("signed"): + summary.append(f"{colorize('🔒 Signed', Colors.GREEN)}") fail_check = False min_val = SEVERITY_MAP.get(args.min_severity, 0) for data in results.values(): - summary.append(f"\n[{colorize(data['label'], Colors.BOLD)}]") + # In quiet mode, skip profile headers unless there's a failure inside? + # Or just print failures. Let's print failures only in quiet mode. + profile_buffer = [] + if not args.quiet: + profile_buffer.append(f"\n[{colorize(data['label'], Colors.BOLD)}]") + + has_failures = False for check in data["checks"]: name = check.get("label") or check['id'] if check["status"] == "FAIL": + has_failures = True sev_col = Colors.RED if check['severity'] in ["CRITICAL", "HIGH"] else Colors.YELLOW - summary.append(f" {colorize('❌', Colors.RED)} [{colorize(check['severity'], sev_col)}] {name}") + profile_buffer.append(f" {colorize('❌', Colors.RED)} [{colorize(check['severity'], sev_col)}] {name}") if check.get("source"): - summary.append(f" File: {check['source']}") + profile_buffer.append(f" File: {check['source']}") if SEVERITY_MAP.get(check["severity"], 0) >= min_val: fail_check = True - elif check["status"] == "EXCEPTION": - summary.append(f" {colorize('⚪', Colors.BLUE)} [EXCEPTION] {name}") - elif check["status"] == "SKIPPED": - summary.append(f" {colorize('⏭️', Colors.CYAN)} [SKIPPED] {name}") - else: - summary.append(f" {colorize('✅', Colors.GREEN)} [PASS] {name}") + elif not args.quiet: + if check["status"] == "EXCEPTION": + profile_buffer.append(f" {colorize('⚪', Colors.BLUE)} [EXCEPTION] {name}") + elif check["status"] == "SKIPPED": + profile_buffer.append(f" {colorize('⏭️', Colors.CYAN)} [SKIPPED] {name}") + else: + profile_buffer.append(f" {colorize('✅', Colors.GREEN)} [PASS] {name}") + + # In quiet mode, only append buffer if there were failures + if not args.quiet or has_failures: + summary.extend(profile_buffer) - status_msg = f"\nFAIL: Blocked by {args.min_severity}+" if fail_check else "\nPASS: Nod granted." - summary.append(colorize(status_msg, Colors.RED if fail_check else Colors.GREEN)) - output_content = "\n".join(summary) if fail_check: + status_msg = f"\nFAIL: Blocked by {args.min_severity}+" + summary.append(colorize(status_msg, Colors.RED)) exit_code = 1 + elif not args.quiet: + status_msg = "\nPASS: Nod granted." + summary.append(colorize(status_msg, Colors.GREEN)) + + output_content = "\n".join(summary) + # Check exit code based on severity for non-text outputs too if SEVERITY_MAP.get(max_sev_label, 0) >= SEVERITY_MAP.get(args.min_severity, 0): exit_code = 1 @@ -121,11 +149,14 @@ def main(): try: with open(args.save_to, "w", encoding="utf-8") as f: f.write(output_content) - print(f"Saved: {args.save_to}") + if not args.quiet: + print(f"Saved: {args.save_to}") except Exception as e: print(f"Error saving file: {e}", file=sys.stderr) sys.exit(1) else: - print(output_content) + # Only print if there is content (quiet mode with no errors might be empty) + if output_content.strip(): + print(output_content) sys.exit(exit_code) diff --git a/src/nod/config.py b/src/nod/config.py index 39cbfe2..04dd47a 100644 --- a/src/nod/config.py +++ b/src/nod/config.py @@ -4,6 +4,7 @@ import urllib.request from typing import Any, Dict, List import yaml +import importlib.resources # Security Constants DEFAULT_TIMEOUT = 15.0 @@ -12,70 +13,97 @@ REGISTRY_BASE_URL = "https://raw.githubusercontent.com/mraml/nod-rules/main/library/" def load_rules(sources: List[str]) -> Dict[str, Any]: - """Loads and merges rules from multiple sources (files/URLs/Dirs/Registry).""" + """ + Loads and merges rules from multiple sources. + Prioritizes user args > local defaults/ > package bundled defaults > rules.yaml + """ merged = {"profiles": {}, "version": "combined"} ssl_context = ssl.create_default_context() ssl_context.check_hostname = True ssl_context.verify_mode = ssl.CERT_REQUIRED def merge(new_data: Dict[str, Any]) -> None: - if not new_data: - return + if not new_data: return for profile, content in new_data.get("profiles", {}).items(): if profile not in merged["profiles"]: merged["profiles"][profile] = content else: merged["profiles"][profile].update(content) + # 1. If sources aren't provided, try to find defaults automatically + if not sources: + # Check local CWD 'defaults' folder first + if os.path.isdir("defaults"): + sources = ["defaults"] + # Fallback to bundled package defaults + elif importlib.resources.is_resource("nod", "defaults"): + # For simplicity, we'll try to find the package path. + try: + # Python 3.9+ pattern + pkg_path = importlib.resources.files("nod") / "defaults" + if pkg_path.is_dir(): + sources = [str(pkg_path)] + except Exception: pass + + # Final fallback + if not sources and os.path.exists("rules.yaml"): + sources = ["rules.yaml"] + + if not sources: + # If we still have nothing, try to load bundled defaults explicitly + try: + pkg_path = importlib.resources.files("nod") / "defaults" + if pkg_path.is_dir(): + sources = [str(pkg_path)] + except Exception: pass + for source in sources: try: - # 1. Handle Registry Shorthand + # Registry Shorthand if source.startswith("registry:"): rule_name = source.split("registry:", 1)[1] - if not rule_name.endswith((".yaml", ".yml")): - rule_name += ".yaml" + if not rule_name.endswith((".yaml", ".yml")): rule_name += ".yaml" source = REGISTRY_BASE_URL + rule_name print(f"Fetching from registry: {source}") - # 2. Handle Remote URLs + # Remote URLs if source.startswith(("http://", "https://")): with urllib.request.urlopen(source, context=ssl_context, timeout=DEFAULT_TIMEOUT) as response: merge(yaml.safe_load(response.read())) - # 3. Handle Local Directories + # Directories (Local or Bundled) elif os.path.isdir(source): for filename in sorted(os.listdir(source)): if filename.endswith(('.yaml', '.yml')): file_path = os.path.join(source, filename) if os.path.getsize(file_path) > MAX_FILE_SIZE: - print(f"Warning: Skipping rule {file_path} (Size limit)", file=sys.stderr) continue with open(file_path, "r", encoding="utf-8") as f_in: merge(yaml.safe_load(f_in)) - # 4. Handle Local Files + # Local Files elif os.path.exists(source): if os.path.getsize(source) > MAX_FILE_SIZE: - print(f"Error: Rule file {source} too large", file=sys.stderr) - sys.exit(1) + print(f"Error: Rule file {source} too large", file=sys.stderr); sys.exit(1) with open(source, "r", encoding="utf-8") as f: merge(yaml.safe_load(f)) else: + # If explicit source passed but not found print(f"Error: Rule source not found: {source}", file=sys.stderr) sys.exit(1) except Exception as e: print(f"Error loading rules from {source}: {e}", file=sys.stderr) sys.exit(1) + return merged def load_ignore(path: str) -> List[str]: - """Loads ignored rule IDs from a file.""" + """Loads ignored rule IDs or file patterns from a file.""" if os.path.exists(path): try: if os.path.getsize(path) <= 1024 * 1024: with open(path, "r", encoding="utf-8") as f: return [line.strip() for line in f if line.strip() and not line.startswith("#")] - except Exception: - pass + except Exception: pass return [] diff --git a/src/nod/scanner.py b/src/nod/scanner.py index e0471d7..10b8355 100644 --- a/src/nod/scanner.py +++ b/src/nod/scanner.py @@ -6,7 +6,7 @@ from datetime import datetime from typing import Dict, Any, Tuple, List from .config import MAX_FILE_SIZE, MAX_TOTAL_SIZE -from .utils import get_line_number, resolve_source +from .utils import get_line_number, resolve_source, should_ignore from .reporters import generate_agent_prompt SEVERITY_MAP = {"CRITICAL": 4, "HIGH": 3, "MEDIUM": 2, "LOW": 1, "INFO": 0} @@ -22,10 +22,17 @@ def _collect_files(self, path: str) -> List[str]: return [path] found = [] for root, dirs, files in os.walk(path): - dirs[:] = [d for d in dirs if not d.startswith('.')] + # Performance Fix: Prune directory tree based on ignores + # Modify dirs in-place to prevent os.walk from entering them + dirs[:] = [d for d in dirs if not should_ignore(os.path.join(root, d), self.ignored_rules)] + for f in files: + fpath = os.path.join(root, f) + if should_ignore(fpath, self.ignored_rules): + continue + if os.path.splitext(f)[1].lower() in {'.md', '.markdown', '.mdx', '.json', '.txt'}: - found.append(os.path.join(root, f)) + found.append(fpath) return found def scan_input(self, path: str, strict: bool = False, version: str = "unknown") -> Tuple[Dict[str, Any], str]: @@ -79,7 +86,7 @@ def scan_input(self, path: str, strict: bool = False, version: str = "unknown") self.attestation = { "tool": "nod", - "version": "2.0.0", + "version": "2.1.0", "policy_version": version, "timestamp": datetime.utcnow().isoformat() + "Z", "files_audited": files, @@ -127,10 +134,12 @@ def _check_req(self, text: str, ext: str, req: Dict, strict: bool) -> Tuple[bool if passed: if missing := [s for s in req.get("must_contain", []) if not re.search(re.escape(s), section, re.I)]: - passed, err = False, f"Missing: {', '.join(missing)}" + passed = False + err = f"Missing: {', '.join(missing)}" for p in req.get("must_match", []): if p.get("pattern") and not re.search(p["pattern"], section, re.I | re.M): - passed, err = False, p.get('message', 'Pattern mismatch') + passed = False + err = p.get('message', 'Pattern mismatch') except re.error: pass return passed, line, start_idx, err @@ -157,23 +166,41 @@ def _audit(self, content: str, ext: str, strict: bool, base: str, def_src: str, if rule_id in skip: status, passed = "SKIPPED", True elif rule_id in self.ignored_rules: status, passed = "EXCEPTION", True else: - if req.get("mode") == "in_all_files" and fmap: - missing_files = [] - for fp, txt in fmap.items(): - p_ok, _, _, _ = self._check_req(txt, os.path.splitext(fp)[1], req, strict) - if not p_ok: missing_files.append(os.path.basename(fp)) - if missing_files: - remediation = f"Missing in: {', '.join(missing_files)}. " + remediation + mode = req.get("mode", "at_least_one") + if mode == "in_all_files": + # Check EVERY file individually + missing = [os.path.basename(fp) for fp, txt in fmap.items() + if not self._check_req(txt, os.path.splitext(fp)[1], req, strict)[0]] + if missing: remediation = f"Missing in: {', '.join(missing)}. " + remediation else: status, passed, src = "PASS", True, "all_files" else: - p_ok, ln, idx, err = self._check_req(content, ext, req, strict) - if p_ok: - status, passed, line = "PASS", True, ln - if not src and idx >= 0: src = resolve_source(content, idx) - if err: remediation = f"{err}. " + remediation - + # Logic Fix for Distributed JSON: + # Instead of checking 'agg_content' which might be mangled JSON text, + # iterate through the files in fmap and see if ANY satisfy the req. + # This preserves 'at_least_one' logic without relying on text aggregation for JSON. + any_pass = False + + # Optimization: Try the aggregate first for Markdown (fast), but iterate for JSON/Mixed + if ext == ".md": + p_ok, ln, idx, err = self._check_req(content, ext, req, strict) + if p_ok: + status, passed, line = "PASS", True, ln + if not src and idx >= 0: src = resolve_source(content, idx) + any_pass = True + elif err: remediation = f"{err}. " + remediation + + # Fallback for JSON or if aggregate failed (double check individual files) + if not any_pass: + for fp, txt in fmap.items(): + p_ok, ln, _, _ = self._check_req(txt, os.path.splitext(fp)[1], req, strict) + if p_ok: + status, passed, line, src = "PASS", True, ln, fp + any_pass = True + break + checks.append({"id": rule_id, "label": req.get("label"), "passed": passed, "status": status, "severity": req.get("severity", "HIGH"), "remediation": remediation, "source": src, "line": line, "control_id": req.get("control_id"), "article": req.get("article")}) + # ... (Red Flags & Cross Refs remain similar, using content for regex is safe for text scan) ... for flag in data.get("red_flags", []): rule_id = flag["pattern"] status, passed, line, src = "PASS", True, 1, def_src @@ -188,13 +215,13 @@ def _audit(self, content: str, ext: str, strict: bool, base: str, def_src: str, except re.error: pass checks.append({"id": rule_id, "label": flag.get("label"), "passed": passed, "status": status, "severity": flag.get("severity", "CRITICAL"), "type": "red_flag", "remediation": flag.get("remediation"), "source": src, "line": line, "control_id": flag.get("control_id"), "article": flag.get("article")}) - for xref in data.get("cross_references", []): + for xr in data.get("cross_references", []): try: - for match in re.finditer(xref["source"], content, re.I | re.M): - expected = match.expand(xref["must_have"]) + for match in re.finditer(xr["source"], content, re.I | re.M): + expected = match.expand(xr["must_have"]) line = get_line_number(content, match.start()) passed = expected in content - checks.append({"id": f"XRef: {match.group(0)}->{expected}", "label": "Cross-Reference Validation", "passed": passed, "status": "PASS" if passed else "FAIL", "severity": xref.get("severity", "HIGH"), "remediation": f"Missing {expected}", "line": line, "source": resolve_source(content, match.start(), def_src)}) + checks.append({"id": f"XRef: {match.group(0)}->{expected}", "label": "Cross-Reference Validation", "passed": passed, "status": "PASS" if passed else "FAIL", "severity": xr.get("severity", "HIGH"), "remediation": f"Missing {expected}", "line": line, "source": resolve_source(content, match.start(), def_src)}) except re.error: pass if strict and ext != ".json" and ("security" in name or "baseline" in name): diff --git a/src/nod/utils.py b/src/nod/utils.py index 60cd96b..7cdef85 100644 --- a/src/nod/utils.py +++ b/src/nod/utils.py @@ -1,6 +1,7 @@ import re import sys import os +import fnmatch class Colors: """ANSI color codes for terminal output.""" @@ -36,3 +37,20 @@ def resolve_source(content: str, index: int) -> str: else: break return best_source + +def should_ignore(path: str, ignore_patterns: list) -> bool: + """Checks if a file path matches any ignore pattern (using fnmatch).""" + # Always ignore common junk folders + DEFAULT_IGNORES = {'node_modules', 'venv', '.venv', '__pycache__', '.git', 'dist', 'build', 'coverage'} + + parts = path.split(os.sep) + if any(p in DEFAULT_IGNORES for p in parts): + return True + + name = os.path.basename(path) + for pattern in ignore_patterns: + # Check if the pattern is a fileglob (e.g. *.log or tests/) + if fnmatch.fnmatch(name, pattern) or fnmatch.fnmatch(path, pattern): + return True + + return False diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..5e158b2 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,94 @@ +import unittest +import os +import tempfile +import shutil +from src.nod.config import load_rules +from src.nod.scanner import Scanner +from src.nod.utils import clean_header + +class TestNodCore(unittest.TestCase): + + def setUp(self): + # Create a dummy rule set for testing + self.test_rules = { + "profiles": { + "test_profile": { + "badge_label": "Test Profile", + "requirements": [ + { + "id": "#+.*Required Header", + "severity": "HIGH", + "remediation": "Must have header", + "must_match": [ + {"pattern": "Value: \\d+", "message": "Must be number"} + ] + } + ], + "red_flags": [ + { + "pattern": "FORBIDDEN_TEXT", + "severity": "CRITICAL", + "remediation": "Do not include forbidden text" + } + ] + } + } + } + self.scanner = Scanner(self.test_rules, ignored_rules=[]) + + def test_clean_header(self): + self.assertEqual(clean_header("#+.*Risk Analysis"), "Risk Analysis") + self.assertEqual(clean_header("## Data Privacy"), "Data Privacy") + self.assertEqual(clean_header("Header.*Pattern"), "Header Pattern") + + def test_scanner_pass(self): + content = "# Required Header\nValue: 123" + results = self.scanner._audit(content, ".md", strict=True, base_dir=".", def_src="test.md", fmap={}) + checks = results["test_profile"]["checks"] + + # Should have 1 check (Requirement) + self.assertEqual(len(checks), 1) + self.assertTrue(checks[0]["passed"]) + self.assertEqual(checks[0]["status"], "PASS") + + def test_scanner_fail_missing_header(self): + content = "# Wrong Header" + results = self.scanner._audit(content, ".md", strict=True, base_dir=".", def_src="test.md", fmap={}) + checks = results["test_profile"]["checks"] + + self.assertFalse(checks[0]["passed"]) + self.assertEqual(checks[0]["status"], "FAIL") + + def test_scanner_fail_deep_validation(self): + # Header present, but value is wrong (ABC instead of number) + content = "# Required Header\nValue: ABC" + results = self.scanner._audit(content, ".md", strict=True, base_dir=".", def_src="test.md", fmap={}) + checks = results["test_profile"]["checks"] + + self.assertFalse(checks[0]["passed"]) + self.assertIn("Must be number", checks[0]["remediation"]) + + def test_red_flag_detection(self): + content = "Some text with FORBIDDEN_TEXT inside." + results = self.scanner._audit(content, ".md", strict=True, base_dir=".", def_src="test.md", fmap={}) + checks = results["test_profile"]["checks"] + + # Should have 2 checks: 1 Req (Fail) + 1 Red Flag (Fail) + self.assertEqual(len(checks), 2) + + flag_check = next(c for c in checks if c["type"] == "red_flag") + self.assertFalse(flag_check["passed"]) + self.assertEqual(flag_check["severity"], "CRITICAL") + + def test_ignore_logic(self): + # Ignore the requirement + self.scanner.ignored_rules = ["#+.*Required Header"] + content = "# Wrong Header" + results = self.scanner._audit(content, ".md", strict=True, base_dir=".", def_src="test.md", fmap={}) + checks = results["test_profile"]["checks"] + + self.assertTrue(checks[0]["passed"]) + self.assertEqual(checks[0]["status"], "EXCEPTION") + +if __name__ == '__main__': + unittest.main()