diff --git a/.aphoriaignore b/.aphoriaignore new file mode 100644 index 0000000..0af3a42 --- /dev/null +++ b/.aphoriaignore @@ -0,0 +1,22 @@ +# Aphoria Ignore Patterns +# +# Additional patterns beyond aphoria.toml excludes. +# Uses gitignore-style syntax. + +# Dashboard application (Next.js, different security model) +applications/stemedb-dashboard/ + +# Disputed application (demo) +applications/disputed/ + +# Community Next.js app (different security context, shell scripts expected) +community/ + +# Python latent signal tools +latent/ + +# Go SDK examples +sdk/go/examples/ + +# .env example files +**/.env.example diff --git a/aphoria.toml b/aphoria.toml new file mode 100644 index 0000000..7e03d0e --- /dev/null +++ b/aphoria.toml @@ -0,0 +1,53 @@ +# Aphoria Configuration for StemeDB +# +# This configures the code-level truth linter for the StemeDB project. + +[project] +name = "stemedb" + +[scan] +# Exclude patterns (supports globs) +exclude = [ + # Build outputs + "target/**", + "node_modules/**", + ".git/**", + + # Intentionally vulnerable demo app + "docs/demo/vulnbank/**", + + # Test fixtures (intentionally insecure patterns) + "**/uat/fixtures/**", + "**/test_fixtures/**", + + # Extractor source files (contain detection patterns as test strings, not real issues) + "applications/aphoria/src/extractors/**", + + # Report modules (contain example output, not real issues) + "applications/aphoria/src/report/**", + + # Learning modules (contain pattern examples) + "applications/aphoria/src/learning/**", + + # Community modules (contain anonymization examples) + "applications/aphoria/src/community/**", +] + +# Include test files in scan (we'll use inline ignores for specific patterns) +include_tests = false + +# Max file size to scan (1MB) +max_file_size = 1048576 + +[extractors] +# All extractors enabled by default + +[corpus] +# Include all corpus sources +include_hardcoded = true +include_rfc = true +include_owasp = true + +[aliases] +# Auto-create aliases when conflicts are detected +auto_create_aliases = true diff --git a/applications/aphoria/Cargo.toml b/applications/aphoria/Cargo.toml index 637d641..59f2f7e 100644 --- a/applications/aphoria/Cargo.toml +++ b/applications/aphoria/Cargo.toml @@ -37,6 +37,7 @@ ignore = "0.4" # Pattern matching regex = "1.10" +globset = "0.4" # Serialization serde = { version = "1.0", features = ["derive"] } diff --git a/applications/aphoria/roadmap.md b/applications/aphoria/roadmap.md index ef37a07..6768a98 100644 --- a/applications/aphoria/roadmap.md +++ b/applications/aphoria/roadmap.md @@ -3030,6 +3030,66 @@ Archived → Pattern removed from active use, historical only --- +## Phase 16: Ignore & Exclusion System ✅ + +> **Vision:** Clean scans by properly excluding test fixtures and intentional vulnerabilities. + +**Problem:** Scans show 210 conflicts but ~102 are test fixtures/demos. Current `exclude` only supports prefix matching, no `.aphoriaignore` file, no inline comments, no ack export. + +### 16.1 Glob Pattern Matching ✅ + +| Task | Status | +|------|--------| +| Replace `starts_with()` with `globset` in `walker/mod.rs` | ✅ | +| Support `**` recursive, `*` wildcard, `?` single char | ✅ | +| Document glob syntax in module docs | ✅ | +| Add tests for pattern matching edge cases | ✅ | +| Backwards compatibility with prefix patterns | ✅ | + +### 16.2 `.aphoriaignore` File ✅ + +| Task | Status | +|------|--------| +| Create `walker/ignore_file.rs` module | ✅ | +| Load `.aphoriaignore` from project root | ✅ | +| Parse gitignore-style patterns with comments | ✅ | +| Merge with `aphoria.toml` excludes | ✅ | +| Support all comment styles (`#`, `//`, etc.) | ✅ | + +### 16.3 Inline Ignore Comments ✅ + +| Task | Status | +|------|--------| +| Create `extractors/ignore_comments.rs` module | ✅ | +| `// aphoria:ignore` same-line suppression | ✅ | +| `// aphoria:ignore-next-line` next-line suppression | ✅ | +| `// aphoria:ignore-block` / `// aphoria:end-ignore` block suppression | ✅ | +| Support multiple comment styles (Rust, Python, C, SQL) | ✅ | +| Integrate with `ExtractorRegistry.extract_all()` | ✅ | + +### 16.4 Acknowledgment Export/Import ✅ + +| Task | Status | +|------|--------| +| Create `ack_file.rs` module | ✅ | +| `aphoria ack export` — export to `.aphoria/acks.toml` | ✅ | +| `aphoria ack import` — import from `.aphoria/acks.toml` | ✅ | +| Preserve expiry and reason fields | ✅ | +| Skip duplicates on import | ✅ | +| Version-controllable TOML format | ✅ | + +### Phase 16 Completion Criteria + +| Metric | Target | +|--------|--------| +| Glob patterns working in `exclude` | ✅ | +| `.aphoriaignore` respected | ✅ | +| Inline comments suppress findings | ✅ | +| Acks exportable to version control | ✅ | +| CLI commands for ack export/import | ✅ | + +--- + ## Enterprise Pilot Success Metrics ### 90-Day Pilot Targets diff --git a/applications/aphoria/src/ack_file.rs b/applications/aphoria/src/ack_file.rs new file mode 100644 index 0000000..5118094 --- /dev/null +++ b/applications/aphoria/src/ack_file.rs @@ -0,0 +1,262 @@ +//! Acknowledgment file export/import. +//! +//! This module handles serializing acknowledgments to a TOML file for +//! version control, and importing them back into the local Episteme. +//! +//! ## File Format +//! +//! The `.aphoria/acks.toml` file contains: +//! +//! ```toml +//! # Aphoria Acknowledgments - version controlled +//! # +//! # This file records intentional exceptions to security policies. +//! # To regenerate: aphoria ack export +//! # To import: aphoria ack import +//! +//! [[ack]] +//! path = "code://rust/myapp/tls/cert_verification" +//! reason = "Self-signed certs in dev environment" +//! expires = "2026-12-31T00:00:00Z" # Optional +//! created = "2026-02-07T10:30:00Z" +//! by = "jordan@example.com" # Optional +//! ``` + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::AphoriaError; + +/// Default path for the ack file relative to project root. +pub const ACK_FILE_PATH: &str = ".aphoria/acks.toml"; + +/// A serialized acknowledgment entry. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AckEntry { + /// The concept path being acknowledged. + pub path: String, + + /// Reason for the acknowledgment. + pub reason: String, + + /// Optional expiry timestamp (ISO 8601 format). + #[serde(skip_serializing_if = "Option::is_none")] + pub expires: Option, + + /// When the acknowledgment was created (ISO 8601 format). + pub created: String, + + /// Who created the acknowledgment. + #[serde(skip_serializing_if = "Option::is_none")] + pub by: Option, +} + +/// Container for all acknowledgments in the file. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AckFile { + /// List of acknowledgments. + #[serde(default, rename = "ack")] + pub acks: Vec, +} + +impl AckFile { + /// Create an empty ack file. + pub fn new() -> Self { + Self { acks: Vec::new() } + } + + /// Add an acknowledgment entry. + pub fn add(&mut self, entry: AckEntry) { + // Check for duplicates by path + if !self.acks.iter().any(|a| a.path == entry.path) { + self.acks.push(entry); + } + } + + /// Load from a TOML file. + pub fn load(path: &Path) -> Result { + if !path.exists() { + return Ok(Self::new()); + } + + let content = std::fs::read_to_string(path) + .map_err(|e| AphoriaError::Io(std::io::Error::new(e.kind(), format!("{e}"))))?; + + toml::from_str(&content) + .map_err(|e| AphoriaError::Config(format!("Failed to parse ack file: {e}"))) + } + + /// Save to a TOML file. + pub fn save(&self, path: &Path) -> Result<(), AphoriaError> { + // Create parent directory if needed + if let Some(parent) = path.parent() { + if !parent.exists() { + std::fs::create_dir_all(parent)?; + } + } + + let header = r#"# Aphoria Acknowledgments - version controlled +# +# This file records intentional exceptions to security policies. +# Each entry represents a finding that has been reviewed and accepted. +# +# To regenerate from database: aphoria ack export +# To import into database: aphoria ack import +# +# Glob patterns in paths are expanded during import. +# Example: "code://*/vulnbank/**" matches all vulnbank paths. + +"#; + + let content = + toml::to_string_pretty(self).map_err(|e| AphoriaError::Config(e.to_string()))?; + + std::fs::write(path, format!("{header}{content}")) + .map_err(|e| AphoriaError::Io(std::io::Error::new(e.kind(), format!("{e}"))))?; + + Ok(()) + } + + /// Get the default path for the ack file. + pub fn default_path(project_root: &Path) -> PathBuf { + project_root.join(ACK_FILE_PATH) + } + + /// Check if an ack file exists at the default location. + pub fn exists(project_root: &Path) -> bool { + Self::default_path(project_root).exists() + } + + /// Get the number of acknowledgments. + pub fn len(&self) -> usize { + self.acks.len() + } + + /// Check if empty. + pub fn is_empty(&self) -> bool { + self.acks.is_empty() + } +} + +/// Parse the JSON payload stored in an acknowledgment assertion. +/// +/// Acknowledgments are stored as assertions with a JSON object containing: +/// - "reason": string +/// - "expires_at": optional timestamp +/// +/// Falls back to plain text for legacy acks. +#[derive(Debug, Clone, Deserialize)] +pub struct AckPayload { + /// The reason for the acknowledgment. + pub reason: String, + /// Optional expiry timestamp (Unix seconds). + #[serde(default)] + pub expires_at: Option, +} + +impl AckPayload { + /// Parse from the assertion's object value. + pub fn parse(text: &str) -> Self { + // Try JSON first + if let Ok(payload) = serde_json::from_str::(text) { + return payload; + } + + // Fall back to plain text reason + Self { reason: text.to_string(), expires_at: None } + } + + /// Format expiry as ISO 8601 string. + pub fn expires_iso(&self) -> Option { + self.expires_at.map(|ts| { + chrono::DateTime::from_timestamp(ts, 0) + .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()) + .unwrap_or_else(|| format!("{ts}")) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_ack_file_roundtrip() { + let temp_dir = TempDir::new().expect("create temp dir"); + let path = temp_dir.path().join(".aphoria/acks.toml"); + + let mut ack_file = AckFile::new(); + ack_file.add(AckEntry { + path: "code://rust/myapp/tls/cert_verification".to_string(), + reason: "Self-signed certs in dev".to_string(), + expires: Some("2026-12-31T00:00:00Z".to_string()), + created: "2026-02-07T10:00:00Z".to_string(), + by: Some("jordan@example.com".to_string()), + }); + ack_file.add(AckEntry { + path: "code://rust/myapp/secrets/api_key".to_string(), + reason: "Test API key".to_string(), + expires: None, + created: "2026-02-07T10:00:00Z".to_string(), + by: None, + }); + + ack_file.save(&path).expect("save ack file"); + + let loaded = AckFile::load(&path).expect("load ack file"); + assert_eq!(loaded.len(), 2); + assert_eq!(loaded.acks[0].path, "code://rust/myapp/tls/cert_verification"); + assert_eq!(loaded.acks[1].reason, "Test API key"); + } + + #[test] + fn test_ack_file_no_duplicates() { + let mut ack_file = AckFile::new(); + + ack_file.add(AckEntry { + path: "code://rust/test".to_string(), + reason: "First".to_string(), + expires: None, + created: "2026-02-07T10:00:00Z".to_string(), + by: None, + }); + + ack_file.add(AckEntry { + path: "code://rust/test".to_string(), + reason: "Duplicate".to_string(), + expires: None, + created: "2026-02-07T10:00:00Z".to_string(), + by: None, + }); + + assert_eq!(ack_file.len(), 1); + assert_eq!(ack_file.acks[0].reason, "First"); + } + + #[test] + fn test_ack_payload_parse_json() { + let json = r#"{"reason":"Test reason","expires_at":1735689600}"#; + let payload = AckPayload::parse(json); + assert_eq!(payload.reason, "Test reason"); + assert_eq!(payload.expires_at, Some(1735689600)); + } + + #[test] + fn test_ack_payload_parse_plain_text() { + let text = "Plain text reason"; + let payload = AckPayload::parse(text); + assert_eq!(payload.reason, "Plain text reason"); + assert!(payload.expires_at.is_none()); + } + + #[test] + fn test_load_nonexistent() { + let temp_dir = TempDir::new().expect("create temp dir"); + let path = temp_dir.path().join("nonexistent.toml"); + + let ack_file = AckFile::load(&path).expect("load should succeed"); + assert!(ack_file.is_empty()); + } +} diff --git a/applications/aphoria/src/bridge.rs b/applications/aphoria/src/bridge.rs index 8e03cad..98cac42 100644 --- a/applications/aphoria/src/bridge.rs +++ b/applications/aphoria/src/bridge.rs @@ -166,7 +166,7 @@ mod tests { value: ObjectValue::Boolean(false), file: "src/client.rs".to_string(), line: 42, - matched_text: "danger_accept_invalid_certs(true)".to_string(), + matched_text: "danger_accept_invalid_certs(true)".to_string(), // aphoria:ignore - Test fixture confidence: 1.0, description: "TLS verification disabled".to_string(), }; @@ -251,7 +251,7 @@ mod tests { value: ObjectValue::Boolean(false), file: "src/client.rs".to_string(), line: 42, - matched_text: "danger_accept_invalid_certs(true)".to_string(), + matched_text: "danger_accept_invalid_certs(true)".to_string(), // aphoria:ignore - Test fixture confidence: 1.0, description: "TLS verification disabled".to_string(), }; diff --git a/applications/aphoria/src/cli/mod.rs b/applications/aphoria/src/cli/mod.rs index ecbaa95..d7c0f73 100644 --- a/applications/aphoria/src/cli/mod.rs +++ b/applications/aphoria/src/cli/mod.rs @@ -90,18 +90,10 @@ pub enum Commands { benchmark: bool, }, - /// Acknowledge a conflict (mark as intentional) + /// Manage acknowledgments (mark conflicts as intentional) Ack { - /// The concept path to acknowledge - concept_path: String, - - /// Reason for acknowledgment - #[arg(short, long)] - reason: String, - - /// Optional expiry for acknowledgment (e.g., "90d" or "2026-12-31") - #[arg(long, alias = "expires-at")] - expires: Option, + #[command(subcommand)] + command: AckCommands, }, /// Bless a code pattern as the authoritative standard @@ -214,6 +206,37 @@ pub enum Commands { }, } +#[derive(Subcommand)] +pub enum AckCommands { + /// Create a new acknowledgment for a conflict + Add { + /// The concept path to acknowledge + concept_path: String, + + /// Reason for acknowledgment + #[arg(short, long)] + reason: String, + + /// Optional expiry for acknowledgment (e.g., "90d" or "2026-12-31") + #[arg(long, alias = "expires-at")] + expires: Option, + }, + + /// Export acknowledgments to .aphoria/acks.toml for version control + Export { + /// Output path (default: .aphoria/acks.toml) + #[arg(short, long)] + output: Option, + }, + + /// Import acknowledgments from .aphoria/acks.toml + Import { + /// Input path (default: .aphoria/acks.toml) + #[arg(short, long)] + input: Option, + }, +} + #[derive(Subcommand)] pub enum CorpusCommands { /// Build the authoritative corpus from configured sources diff --git a/applications/aphoria/src/extractors/ignore_comments.rs b/applications/aphoria/src/extractors/ignore_comments.rs new file mode 100644 index 0000000..90c2683 --- /dev/null +++ b/applications/aphoria/src/extractors/ignore_comments.rs @@ -0,0 +1,392 @@ +//! Inline ignore comment parsing. +//! +//! This module handles parsing `// aphoria:ignore` comments that suppress +//! specific findings. +//! +//! ## Supported Syntax +//! +//! ### Single-Line Ignore +//! +//! Ignores the finding on the same line: +//! +//! ```text +//! let password = "test123"; // aphoria:ignore - Test credential +//! ``` +//! +//! ### Next-Line Ignore +//! +//! Ignores the finding on the following line: +//! +//! ```text +//! // aphoria:ignore-next-line - Intentional for testing +//! .danger_accept_invalid_certs(true) +//! ``` +//! +//! ### Block Ignore +//! +//! Ignores all findings within a block: +//! +//! ```text +//! // aphoria:ignore-block - Test fixtures +//! fn test_patterns() { +//! let key = "sk_test_abc123"; +//! let password = "hunter2"; +//! } +//! // aphoria:end-ignore +//! ``` +//! +//! ## Comment Variants +//! +//! The parser supports various comment styles: +//! +//! - `// aphoria:ignore` (Rust, Go, TypeScript, JavaScript, C, C++) +//! - `# aphoria:ignore` (Python, Ruby, YAML, Shell) +//! - `/* aphoria:ignore */` (CSS, block comments) +//! - `-- aphoria:ignore` (SQL) +//! - `` (HTML, XML) + +use std::collections::HashSet; + +use regex::Regex; + +/// Parses ignore comments from file content and tracks ignored line numbers. +#[derive(Debug)] +pub struct IgnoreCommentParser { + /// Lines that should be ignored (1-indexed to match ExtractedClaim.line). + ignored_lines: HashSet, +} + +impl IgnoreCommentParser { + /// Parse ignore comments from file content. + /// + /// Returns a parser with the set of ignored line numbers. + pub fn parse(content: &str) -> Self { + let mut ignored_lines = HashSet::new(); + + // Track if we're in an ignore block + let mut in_block = false; + + for (line_idx, line) in content.lines().enumerate() { + let line_num = line_idx + 1; // 1-indexed + + // Check for block start/end + if contains_block_start(line) { + in_block = true; + // The block start line itself is not ignored (it's a comment) + continue; + } + + if contains_block_end(line) { + in_block = false; + // The block end line itself is not ignored (it's a comment) + continue; + } + + // If we're in a block, ignore this line + if in_block { + ignored_lines.insert(line_num); + continue; + } + + // Check for same-line ignore + if contains_same_line_ignore(line) { + ignored_lines.insert(line_num); + continue; + } + + // Check for next-line ignore (look at previous line) + if line_idx > 0 { + let prev_line = content.lines().nth(line_idx - 1).unwrap_or(""); + if contains_next_line_ignore(prev_line) { + ignored_lines.insert(line_num); + } + } + } + + Self { ignored_lines } + } + + /// Check if a line number should be ignored. + /// + /// Line numbers are 1-indexed (matching ExtractedClaim.line). + pub fn is_ignored(&self, line: usize) -> bool { + self.ignored_lines.contains(&line) + } + + /// Get the set of ignored line numbers. + #[allow(dead_code)] + pub fn ignored_lines(&self) -> &HashSet { + &self.ignored_lines + } + + /// Get the count of ignored lines. + #[allow(dead_code)] + pub fn ignored_count(&self) -> usize { + self.ignored_lines.len() + } +} + +/// Check if a line contains a same-line ignore comment. +fn contains_same_line_ignore(line: &str) -> bool { + // Match variations: + // // aphoria:ignore + // # aphoria:ignore + // /* aphoria:ignore */ + // -- aphoria:ignore + // aphoria:ignore (bare, for XML comments etc.) + // + // But NOT: + // // aphoria:ignore-next-line + // // aphoria:ignore-block + // // aphoria:end-ignore + + // Using lazy_static would be better, but we'll keep it simple + let patterns = [ + r"//\s*aphoria:ignore(?:\s|$|-\s)", + r"#\s*aphoria:ignore(?:\s|$|-\s)", + r"/\*\s*aphoria:ignore(?:\s|$|-\s)", + r"--\s*aphoria:ignore(?:\s|$|-\s)", + r"