diff --git a/action.yml b/action.yml index dcbbfc4..b109084 100644 --- a/action.yml +++ b/action.yml @@ -40,6 +40,7 @@ runs: INPUT_JAVASCRIPT_DISABLED_RULES: ${{ inputs.javascript_disabled_rules }} INPUT_JAVASCRIPT_ENABLED_RULES: ${{ inputs.javascript_enabled_rules }} INPUT_JAVASCRIPT_SAST_ENABLED: ${{ inputs.javascript_sast_enabled }} + INPUT_SAST_IGNORE_OVERRIDES: ${{ inputs.sast_ignore_overrides }} INPUT_JAVA_DISABLED_RULES: ${{ inputs.java_disabled_rules }} INPUT_JAVA_ENABLED_RULES: ${{ inputs.java_enabled_rules }} INPUT_JAVA_SAST_ENABLED: ${{ inputs.java_sast_enabled }} @@ -96,6 +97,7 @@ runs: INPUT_PR_LABEL_CRITICAL: ${{ inputs.pr_label_critical }} INPUT_PR_LABEL_HIGH: ${{ inputs.pr_label_high }} INPUT_PR_LABEL_MEDIUM: ${{ inputs.pr_label_medium }} + INPUT_PR_LABEL_LOW: ${{ inputs.pr_label_low }} inputs: workspace: @@ -246,6 +248,10 @@ inputs: description: "Enable JavaScript/TypeScript SAST scanning" required: false default: "false" + sast_ignore_overrides: + description: "Comma-separated list of SAST ignore overrides in rule_id or rule_id:path format" + required: false + default: "" jira_api_token: description: "Jira Api Token" required: false @@ -450,7 +456,11 @@ inputs: description: "Label name for medium severity findings" required: false default: "security: medium" + pr_label_low: + description: "Label name for low severity findings" + required: false + default: "security: low" branding: icon: "shield" - color: "blue" + color: "purple" diff --git a/docs/github-action.md b/docs/github-action.md index 3297c12..f467b5a 100644 --- a/docs/github-action.md +++ b/docs/github-action.md @@ -638,8 +638,29 @@ jobs: # JavaScript with custom rules javascript_sast_enabled: 'true' javascript_enabled_rules: 'eval-usage,prototype-pollution' + + # Ignore one or more SAST rules globally or for exact repo-relative files + sast_ignore_overrides: 'js-sql-injection:index.js' ``` +`sast_ignore_overrides` supports: +- `rule_id` to ignore a SAST rule everywhere in the repo +- `rule_id:path` to ignore a SAST rule for one exact repo-relative file + +Examples: +- `js-sql-injection` +- `js-sql-injection:index.js` +- `js-sql-injection:src/unsafe/demo.js` +- `js-express-async-no-error-handler,js-sql-injection:index.js,js-missing-helmet` + +Notes: +- Paths must be exact repo-relative paths using `/` separators after normalization. +- Windows-style input such as `src\\unsafe\\demo.js` is accepted and normalized automatically. +- Globs and directory-prefix matching are not supported in this first version. +- A `rule_id:path` entry is an exact `rule_id AND path` match. If the path does not match, Socket Basics will not fall back to a rule-only ignore. +- Broad dashboard rule disables such as `_disabled_rules` still ignore that rule everywhere in the repo. If both are configured, the broad disabled-rule behavior can make it look like a narrow path override matched when it did not. +- In `.socket.facts.json`, ignored alerts include `actionReason` so you can see whether the ignore came from `sast_ignore_override` or `disabled_rule`. + ## Configuration Reference ### All Available Inputs @@ -667,6 +688,7 @@ See [`action.yml`](../action.yml) for the complete list of inputs. **Rule Configuration (per language):** - `_enabled_rules` — Comma-separated rules to enable - `_disabled_rules` — Comma-separated rules to disable +- `sast_ignore_overrides` — Comma-separated `rule_id` or `rule_id:path` SAST ignore overrides **Security Scanning:** - `secret_scanning_enabled` — Enable secret scanning @@ -750,6 +772,20 @@ permissions: 2. Check that `socket_org` and `socket_security_api_key` are set correctly 3. Confirm API key has required permissions in Socket Dashboard +### `sast_ignore_overrides` Seems Too Broad + +**Problem:** A `rule_id:path` override appears to ignore findings outside the specified file. + +**Likely cause:** The rule is also disabled more broadly in dashboard settings or other config through `_disabled_rules`. + +**How to confirm:** +1. Open the generated `.socket.facts.json` +2. Find the ignored alert and inspect `actionReason` +3. `actionReason: "sast_ignore_override"` means the exact path override matched +4. `actionReason: "disabled_rule"` means the finding was ignored by a broad rule disable instead + +**Additional signal:** If the configured path does not exist under the workspace, Socket Basics logs a warning and does not fall back to rule-only matching. + ### High Memory Usage **Problem:** Action runs out of memory. diff --git a/docs/github-pr-comment-guide.md b/docs/github-pr-comment-guide.md index 0cd949d..1466612 100644 --- a/docs/github-pr-comment-guide.md +++ b/docs/github-pr-comment-guide.md @@ -236,6 +236,7 @@ Automatically tag PRs with severity-based labels **and matching colors**. - `security: critical` 🔴 - Red (`#D73A4A`) - `security: high` 🟠 - Orange (`#D93F0B`) - `security: medium` 🟡 - Yellow (`#FBCA04`) +- `security: low` ⚪ - Light gray (`#E4E4E4`) **Smart color detection:** Labels are automatically created with colors matching the severity emojis. If you customize label names, the system intelligently detects severity keywords and applies appropriate colors: @@ -246,7 +247,9 @@ pr_label_high: 'security-high' # Gets orange color automatically ``` **How it works:** -- First scan checks for critical → high → medium (highest severity wins) +- Each run keeps only the current highest-severity managed PR label: critical → high → medium → low +- Stale managed severity labels from earlier runs are removed automatically +- If a later run has no active findings, the managed severity label is removed - Labels are created automatically if they don't exist - Existing labels are not modified (preserves your customizations) - Requires a token with `repo` scope to create new labels; without it, label creation may fail (comments still post) @@ -257,6 +260,7 @@ pr_labels_enabled: 'true' pr_label_critical: 'vulnerability: critical' pr_label_high: 'vulnerability: high' pr_label_medium: 'vulnerability: medium' +pr_label_low: 'vulnerability: low' ``` **Disable:** @@ -280,6 +284,22 @@ The logo is a 32px PNG rendered at 24x24 for retina-crisp display, with a transp --- +### 9. All-Clear Comment Updates + +When a later Socket Basics run no longer has active findings for a previously-reported scanner section, the existing PR comment section is updated in place instead of being left stale or deleted. + +**Behavior:** +- Existing Socket-managed sections are preserved for auditability +- Stale findings content is replaced with a short all-clear message +- This keeps the PR history readable while making it obvious that the latest run is clean + +**Example all-clear message:** +```text +✅ Socket Basics found no active findings in the latest run. +``` + +--- + ## 📋 Configuration Reference ### All Options @@ -295,6 +315,7 @@ The logo is a 32px PNG rendered at 24x24 for retina-crisp display, with a transp | `pr_label_critical` | `"security: critical"` | string | Label name for critical findings | | `pr_label_high` | `"security: high"` | string | Label name for high findings | | `pr_label_medium` | `"security: medium"` | string | Label name for medium findings | +| `pr_label_low` | `"security: low"` | string | Label name for low findings | ### Configuration Methods diff --git a/docs/parameters.md b/docs/parameters.md index c30e785..deb0268 100644 --- a/docs/parameters.md +++ b/docs/parameters.md @@ -241,6 +241,32 @@ socket-basics --go --go-enabled-rules "error-handling,sql-injection" - `--rust-enabled-rules` / `--rust-disabled-rules` - `--elixir-enabled-rules` / `--elixir-disabled-rules` +### `--sast-ignore-overrides SAST_IGNORE_OVERRIDES` +Comma-separated list of SAST ignore overrides in `rule_id` or `rule_id:path` format. + +**Environment Variable:** `INPUT_SAST_IGNORE_OVERRIDES` + +**Examples:** +```bash +# Ignore a rule everywhere in the repo +socket-basics --javascript --sast-ignore-overrides "js-sql-injection" + +# Ignore a rule only for one exact repo-relative file +socket-basics --javascript --sast-ignore-overrides "js-sql-injection:index.js" + +# Mix rule-only and rule+path overrides in one comma-separated list +socket-basics --javascript --sast-ignore-overrides "js-express-async-no-error-handler,js-sql-injection:index.js,js-missing-helmet" +``` + +Notes: +- Paths must be exact repo-relative paths. +- Paths are normalized to forward-slash form, so Windows-style input such as `src\\unsafe\\demo.js` is accepted. +- Globs and directory-prefix matching are not supported in this first version. +- A `rule_id:path` entry uses exact `rule_id AND path` matching. A bad path does not degrade into a rule-only ignore. +- If the configured path does not exist under the current workspace, Socket Basics logs a warning to help catch typos or copied paths from another repo. +- If the same rule is also disabled via `-disabled-rules` or dashboard policy, that broader ignore still applies across the repo. +- Ignored alerts in `.socket.facts.json` include `actionReason` so you can distinguish `sast_ignore_override` from `disabled_rule`. + ### `--opengrep-notify OPENGREP_NOTIFY` Notification method for OpenGrep SAST results (e.g., console, slack). @@ -520,6 +546,7 @@ All notification integrations support environment variables as alternatives to C | Variable | Description | |----------|-------------| | `INPUT_OPENGREP_RULES_DIR` | Custom directory containing SAST rules | +| `INPUT_SAST_IGNORE_OVERRIDES` | Comma-separated `rule_id` or `rule_id:path` SAST ignore overrides | ## Configuration File @@ -537,6 +564,7 @@ You can provide configuration via a JSON file using `--config`: "python_sast_enabled": true, "javascript_sast_enabled": true, "go_sast_enabled": true, + "sast_ignore_overrides": "js-sql-injection:index.js", "secrets_enabled": true, "trufflehog_exclude_dir": "node_modules,vendor,dist,.git", diff --git a/socket_basics/connectors.yaml b/socket_basics/connectors.yaml index 815a85e..7df8b03 100644 --- a/socket_basics/connectors.yaml +++ b/socket_basics/connectors.yaml @@ -191,6 +191,12 @@ connectors: env_variable: INPUT_JAVASCRIPT_DISABLED_RULES type: str default: "" + - name: sast_ignore_overrides + option: --sast-ignore-overrides + description: "Comma-separated list of SAST ignore overrides in rule_id or rule_id:path format" + env_variable: INPUT_SAST_IGNORE_OVERRIDES + type: str + default: "" # Go rule configuration - name: go_enabled_rules diff --git a/socket_basics/core/config.py b/socket_basics/core/config.py index 55512cf..6c828be 100644 --- a/socket_basics/core/config.py +++ b/socket_basics/core/config.py @@ -15,6 +15,160 @@ logger = logging.getLogger(__name__) +def _normalize_path_parts(path_value: str | None) -> List[str] | None: + """Normalize a path-like string into comparable POSIX-style path segments.""" + if path_value is None: + return None + + path_str = str(path_value).strip() + if not path_str: + return None + + path_str = path_str.replace('\\', '/') + while path_str.startswith('./'): + path_str = path_str[2:] + path_str = path_str.lstrip('/') + + normalized_parts: List[str] = [] + for part in path_str.split('/'): + if not part or part == '.': + continue + if part == '..': + return None + normalized_parts.append(part) + + return normalized_parts or None + + +def _get_workspace_prefix_candidates() -> List[List[str]]: + """Return normalized workspace roots from common CI systems and local cwd.""" + candidate_values: List[str] = [] + for env_var in ( + 'BITBUCKET_CLONE_DIR', + 'BUILD_SOURCESDIRECTORY', + 'BUILDKITE_BUILD_CHECKOUT_PATH', + 'CI_PROJECT_DIR', + 'CIRCLE_WORKING_DIRECTORY', + 'DRONE_WORKSPACE', + 'GITHUB_WORKSPACE', + 'SYSTEM_DEFAULTWORKINGDIRECTORY', + 'WORKSPACE', + ): + env_value = os.getenv(env_var) + if env_value: + candidate_values.append(env_value) + + try: + candidate_values.append(os.getcwd()) + except Exception: + pass + + normalized_candidates: List[List[str]] = [] + seen: set[tuple[str, ...]] = set() + for value in candidate_values: + parts = _normalize_path_parts(value) + if not parts: + continue + parts_key = tuple(parts) + if parts_key in seen: + continue + seen.add(parts_key) + normalized_candidates.append(parts) + + # Check longer, more specific prefixes first. + normalized_candidates.sort(key=len, reverse=True) + return normalized_candidates + + +def normalize_repo_relative_path(path_value: str | None) -> str | None: + """Normalize a repo-relative path to the POSIX form emitted by SAST alerts.""" + normalized_parts = _normalize_path_parts(path_value) + if not normalized_parts: + return None + + for workspace_parts in _get_workspace_prefix_candidates(): + if len(normalized_parts) > len(workspace_parts) and normalized_parts[:len(workspace_parts)] == workspace_parts: + normalized_parts = normalized_parts[len(workspace_parts):] + break + + normalized = '/'.join(normalized_parts) + return normalized or None + + +def parse_sast_ignore_overrides(raw_value: str | None) -> List[Dict[str, str | None]]: + """Parse `rule_id` and `rule_id:path` ignore override entries.""" + overrides: List[Dict[str, str | None]] = [] + seen: set[tuple[str, str | None]] = set() + + if not raw_value: + return overrides + + for raw_entry in str(raw_value).split(','): + entry = raw_entry.strip() + if not entry: + continue + + rule_id = entry + path = None + + if ':' in entry: + rule_id, path_part = entry.split(':', 1) + rule_id = rule_id.strip() + path_part = path_part.strip() + + if not rule_id or not path_part: + logger.warning("Ignoring malformed SAST ignore override: %r", entry) + continue + + if any(ch in path_part for ch in ('*', '?', '[')): + logger.warning( + "Ignoring unsupported SAST ignore override with glob syntax: %r", + entry, + ) + continue + + path = normalize_repo_relative_path(path_part) + if not path: + logger.warning("Ignoring invalid repo-relative path in SAST override: %r", entry) + continue + else: + rule_id = rule_id.strip() + if not rule_id: + logger.warning("Ignoring malformed SAST ignore override: %r", entry) + continue + + key = (rule_id, path) + if key in seen: + continue + seen.add(key) + overrides.append({'rule_id': rule_id, 'path': path}) + + return overrides + + +def alert_matches_sast_ignore_override( + alert: Dict[str, Any], + override: Dict[str, str | None], +) -> bool: + """Return True when an alert matches a parsed SAST ignore override.""" + props = alert.get('props', {}) or {} + rule_id = props.get('ruleId') or alert.get('title') or alert.get('ruleId') + if not rule_id or rule_id != override.get('rule_id'): + return False + + override_path = override.get('path') + if not override_path: + return True + + alert_path = ( + props.get('filePath') + or (alert.get('location') or {}).get('path') + or '' + ) + normalized_alert_path = normalize_repo_relative_path(alert_path) + return normalized_alert_path == override_path + + class Config: """Configuration object that provides unified access to all settings""" @@ -112,6 +266,45 @@ def get_action_for_severity(self, severity: str) -> str: else: # Default action for unknown severities return 'monitor' + + def get_sast_ignore_overrides(self) -> List[Dict[str, str | None]]: + """Return parsed SAST ignore overrides from config.""" + if not hasattr(self, '_sast_ignore_overrides_cache'): + raw_value = self.get('sast_ignore_overrides', '') + overrides = parse_sast_ignore_overrides(raw_value) + + workspace_value = self.get('workspace') or os.getcwd() + try: + workspace_root = Path(str(workspace_value)).expanduser() + except Exception: + workspace_root = Path(os.getcwd()) + + for override in overrides: + override_path = override.get('path') + if not override_path: + continue + + try: + candidate = workspace_root.joinpath(*str(override_path).split('/')) + if not candidate.exists(): + logger.warning( + "SAST ignore override path %r for rule %r does not exist under workspace %s; " + "exact path overrides require a repo-relative file path and will not fall back to " + "rule-only matching.", + override_path, + override.get('rule_id'), + workspace_root, + ) + except Exception: + logger.debug( + "Failed to validate SAST ignore override path %r under workspace %r", + override_path, + workspace_value, + exc_info=True, + ) + + self._sast_ignore_overrides_cache = overrides + return self._sast_ignore_overrides_cache @property def repo(self) -> str: diff --git a/socket_basics/core/connector/normalizer.py b/socket_basics/core/connector/normalizer.py index 8837aa2..cd8c1c2 100644 --- a/socket_basics/core/connector/normalizer.py +++ b/socket_basics/core/connector/normalizer.py @@ -11,6 +11,7 @@ from typing import Any, Dict, List, Tuple import logging import os +from ..config import alert_matches_sast_ignore_override logger = logging.getLogger(__name__) @@ -38,6 +39,18 @@ def _normalize_alert(a: Dict[str, Any], connector: Any | None = None, default_ge a['severity'] = a['severity'].lower() # Minimal normalization: lowercase severity and ensure action exists + # Honor local SAST ignore overrides before deriving actions from severity. + try: + if connector and hasattr(connector, 'config') and hasattr(connector.config, 'get_sast_ignore_overrides'): + for override in connector.config.get_sast_ignore_overrides(): + if alert_matches_sast_ignore_override(a, override): + logger.debug("Alert matched sast_ignore_overrides entry %s", override) + a['action'] = 'ignore' + a['actionReason'] = 'sast_ignore_override' + return a + except Exception: + logger.debug('Failed to check SAST ignore overrides for alert', exc_info=True) + # Check if this alert's rule is in the disabled rules list for any language # If so, set action to 'ignore' regardless of severity try: @@ -61,6 +74,7 @@ def _normalize_alert(a: Dict[str, Any], connector: Any | None = None, default_ge if rule_id in disabled_rules: logger.debug(f"Rule {rule_id} is disabled via {param}, setting action to 'ignore'") a['action'] = 'ignore' + a['actionReason'] = 'disabled_rule' return a except Exception: pass diff --git a/socket_basics/core/connector/opengrep/__init__.py b/socket_basics/core/connector/opengrep/__init__.py index 0cf4135..47dc9d5 100644 --- a/socket_basics/core/connector/opengrep/__init__.py +++ b/socket_basics/core/connector/opengrep/__init__.py @@ -720,6 +720,9 @@ def generate_notifications(self, components: List[Dict[str, Any]]) -> Dict[str, groups: Dict[str, List[Dict[str, Any]]] = {} for c in comps_map.values(): for a in c.get('alerts', []): + alert_action = (a.get('action') or '').strip().lower() + if alert_action == 'ignore': + continue # Filter by severity - only include alerts that match allowed severities alert_severity = (a.get('severity') or '').strip().lower() if alert_severity and hasattr(self, 'allowed_severities') and alert_severity not in self.allowed_severities: diff --git a/socket_basics/core/notification/github_pr_notifier.py b/socket_basics/core/notification/github_pr_notifier.py index 555d03e..7a32a74 100644 --- a/socket_basics/core/notification/github_pr_notifier.py +++ b/socket_basics/core/notification/github_pr_notifier.py @@ -1,5 +1,6 @@ from typing import Any, Dict, List, Optional import logging +from urllib.parse import quote from socket_basics.core.notification.base import BaseNotifier from socket_basics.core.config import get_github_token, get_github_repository, get_github_pr_number @@ -35,14 +36,11 @@ def __init__(self, params: Dict[str, Any] | None = None): def notify(self, facts: Dict[str, Any]) -> None: notifications = facts.get('notifications', []) or [] + labels_enabled = self.config.get('pr_labels_enabled', True) if not isinstance(notifications, list): logger.error('GithubPRNotifier: only supports new format - list of dicts with title/content') return - - if not notifications: - logger.info('GithubPRNotifier: no notifications present; skipping') - return # Get full scan URL if available and store it for use in truncation self.full_scan_url = facts.get('full_scan_html_url') @@ -56,7 +54,21 @@ def notify(self, facts: Dict[str, Any]) -> None: else: logger.warning('GithubPRNotifier: skipping invalid notification item: %s', type(item)) + notification_section_types = self._extract_section_types_from_notifications(valid_notifications) + facts_section_types = self._infer_section_types_from_facts(facts) + if not valid_notifications: + if labels_enabled: + pr_number = self._get_pr_number() + if pr_number: + self._reconcile_pr_labels(pr_number, []) + self._replace_existing_sections_with_all_clear( + pr_number, + section_types=facts_section_types, + ) + else: + logger.warning('GithubPRNotifier: unable to determine PR number for label reconciliation') + logger.info('GithubPRNotifier: no notifications present; skipping comments') return # Get PR number for current branch @@ -115,11 +127,46 @@ def notify(self, facts: Dict[str, Any]) -> None: else: logger.error('GithubPRNotifier: failed to post individual comment') + stale_section_types = facts_section_types - notification_section_types + if stale_section_types: + self._replace_existing_sections_with_all_clear(pr_number, section_types=stale_section_types) + # Add labels to PR if enabled - if self.config.get('pr_labels_enabled', True) and pr_number: + if labels_enabled and pr_number: labels = self._determine_pr_labels(valid_notifications) - if labels: - self._add_pr_labels(pr_number, labels) + self._reconcile_pr_labels(pr_number, labels) + def _managed_pr_label_config(self) -> Dict[str, str]: + """Return the managed severity label names configured for PRs.""" + return { + 'critical': self.config.get('pr_label_critical', 'security: critical'), + 'high': self.config.get('pr_label_high', 'security: high'), + 'medium': self.config.get('pr_label_medium', 'security: medium'), + 'low': self.config.get('pr_label_low', 'security: low'), + } + + def _get_label_color_info(self, label: str) -> Optional[tuple[str, str]]: + """Infer color/description for managed or custom severity labels.""" + label_colors = { + self.config.get('pr_label_critical', 'security: critical'): ('D73A4A', 'Critical security vulnerabilities'), + self.config.get('pr_label_high', 'security: high'): ('D93F0B', 'High severity security issues'), + self.config.get('pr_label_medium', 'security: medium'): ('FBCA04', 'Medium severity security issues'), + self.config.get('pr_label_low', 'security: low'): ('E4E4E4', 'Low severity security issues'), + } + color_info = label_colors.get(label) + if color_info: + return color_info + + label_lower = label.lower() + if 'critical' in label_lower: + return ('D73A4A', 'Critical security vulnerabilities') + if 'high' in label_lower: + return ('D93F0B', 'High severity security issues') + if 'medium' in label_lower: + return ('FBCA04', 'Medium severity security issues') + if 'low' in label_lower: + return ('E4E4E4', 'Low severity security issues') + return None + def _send_pr_comment(self, facts: Dict[str, Any], title: str, content: str) -> None: """Send a single PR comment with title and content.""" @@ -252,6 +299,70 @@ def _extract_section_markers(self, content: str) -> Optional[Dict[str, str]]: return None + def _extract_all_section_types(self, comment_body: str) -> List[str]: + """Extract all managed section markers from a comment body.""" + import re + + pattern = r'' + return re.findall(pattern, comment_body or '') + + def _extract_section_types_from_notifications(self, notifications: List[Dict[str, Any]]) -> set[str]: + """Return the managed section types present in notifier payload content.""" + section_types: set[str] = set() + for notification in notifications or []: + if not isinstance(notification, dict): + continue + section_match = self._extract_section_markers(notification.get('content', '')) + if section_match and section_match.get('type'): + section_types.add(section_match['type']) + return section_types + + def _infer_section_types_from_facts(self, facts: Dict[str, Any]) -> set[str]: + """Infer managed PR section types represented by the current run's components.""" + section_types: set[str] = set() + + for component in facts.get('components', []) or []: + if not isinstance(component, dict): + continue + + alerts = component.get('alerts', []) or [] + for alert in alerts: + if not isinstance(alert, dict): + continue + + generated_by = (alert.get('generatedBy') or '').strip().lower() + subtype = (alert.get('subType') or alert.get('subtype') or '').strip().lower() + + if subtype.startswith('sast-'): + section_types.add(subtype) + continue + + if generated_by == 'socket-tier1' or subtype == 'socket-tier1': + section_types.add('socket-tier1') + continue + + if generated_by == 'trufflehog' or subtype == 'secrets': + section_types.add('trufflehog-secrets') + continue + + if generated_by.startswith('trivy-') or subtype in {'dockerfile', 'container-image'}: + section_types.add('trivy-container') + continue + + return section_types + + def _extract_section_title(self, section_content: str) -> str: + """Extract the display title from a wrapped PR comment section.""" + import re + + for line in (section_content or '').splitlines(): + stripped = line.strip() + if stripped.startswith('## '): + title = stripped[3:].strip() + title = re.sub(r']+>\s*', '', title).strip() + return title or 'Socket Security' + return 'Socket Security' + def _find_comment_with_section(self, comments: List[Dict[str, Any]], section_type: str) -> Optional[Dict[str, Any]]: """Find an existing comment that contains the given section type.""" import re @@ -277,6 +388,51 @@ def _update_section_in_comment(self, comment_body: str, section_type: str, new_s return updated_body + def _build_all_clear_section(self, section_type: str, existing_section_content: str) -> str: + """Build an all-clear replacement for an existing managed section.""" + from socket_basics.core.notification import github_pr_helpers as helpers + + title = self._extract_section_title(existing_section_content) + body = "✅ Socket Basics found no active findings in the latest run." + return helpers.wrap_pr_comment_section(section_type, title, body, self.full_scan_url) + + def _replace_existing_sections_with_all_clear(self, pr_number: int, section_types: Optional[set[str]] = None) -> None: + """Rewrite existing managed PR comment sections to an all-clear state.""" + existing_comments = self._get_pr_comments(pr_number) + for comment in existing_comments: + original_body = comment.get('body', '') + if not original_body: + continue + + updated_body = original_body + changed = False + for section_type in self._extract_all_section_types(original_body): + if section_types is not None and section_type not in section_types: + continue + section_match = self._extract_section_markers(updated_body) + if not section_match or section_match.get('type') != section_type: + import re + pattern = rf'.*?' + match = re.search(pattern, updated_body, re.DOTALL) + if not match: + continue + section_content = match.group(0) + else: + section_content = section_match['content'] + + all_clear_section = self._build_all_clear_section(section_type, section_content) + next_body = self._update_section_in_comment(updated_body, section_type, all_clear_section) + if next_body != updated_body: + updated_body = next_body + changed = True + + if changed: + success = self._update_comment(pr_number, comment['id'], updated_body) + if success: + logger.info('GithubPRNotifier: updated existing comment %s to all-clear state', comment['id']) + else: + logger.error('GithubPRNotifier: failed to update comment %s to all-clear state', comment['id']) + def _truncate_comment_if_needed(self, comment_body: str, full_scan_url: Optional[str] = None) -> str: """Truncate comment if it exceeds GitHub's character limit. @@ -423,19 +579,93 @@ def _ensure_label_exists_with_color(self, label_name: str, color: str, descripti logger.info('GithubPRNotifier: created label "%s" with color #%s', label_name, color) return True else: - logger.warning('GithubPRNotifier: failed to create label "%s": %s', - label_name, create_resp.status_code) + logger.warning( + 'GithubPRNotifier: failed to create label "%s": %s %s', + label_name, + create_resp.status_code, + create_resp.text[:200], + ) return False else: - logger.warning('GithubPRNotifier: unexpected response checking label: %s', resp.status_code) + logger.warning( + 'GithubPRNotifier: unexpected response checking label "%s": %s %s', + label_name, + resp.status_code, + resp.text[:200], + ) return False except Exception as e: logger.debug('GithubPRNotifier: exception ensuring label exists: %s', e) return False + def _ensure_pr_labels_exist(self, labels: List[str]) -> None: + """Ensure desired labels exist in the repository with appropriate colors.""" + for label in labels: + color_info = self._get_label_color_info(label) + if color_info: + color, description = color_info + self._ensure_label_exists_with_color(label, color, description) + + def _get_current_pr_label_names(self, pr_number: int) -> List[str]: + """Fetch current label names for the PR.""" + if not self.repository: + return [] + + try: + import requests + headers = { + 'Authorization': f'token {self.token}', + 'Accept': 'application/vnd.github.v3+json' + } + url = f"{self.api_base}/repos/{self.repository}/issues/{pr_number}/labels" + resp = requests.get(url, headers=headers, timeout=10) + if resp.status_code == 200: + payload = resp.json() + return [label.get('name') for label in payload if isinstance(label, dict) and label.get('name')] + logger.warning( + 'GithubPRNotifier: failed to fetch current labels for PR %s: %s %s', + pr_number, + resp.status_code, + resp.text[:200], + ) + except Exception as e: + logger.error('GithubPRNotifier: exception fetching current labels: %s', e) + return [] + + def _remove_pr_label(self, pr_number: int, label: str) -> bool: + """Remove a label from a PR.""" + if not self.repository or not label: + return False + + try: + import requests + headers = { + 'Authorization': f'token {self.token}', + 'Accept': 'application/vnd.github.v3+json' + } + encoded_label = quote(label, safe='') + url = f"{self.api_base}/repos/{self.repository}/issues/{pr_number}/labels/{encoded_label}" + resp = requests.delete(url, headers=headers, timeout=10) + if resp.status_code == 200: + logger.info('GithubPRNotifier: removed label from PR %s: %s', pr_number, label) + return True + if resp.status_code == 404: + logger.debug('GithubPRNotifier: label %s already absent from PR %s', label, pr_number) + return True + logger.warning( + 'GithubPRNotifier: failed to remove label "%s" from PR %s: %s %s', + label, + pr_number, + resp.status_code, + resp.text[:200], + ) + except Exception as e: + logger.error('GithubPRNotifier: exception removing label %s: %s', label, e) + return False + def _add_pr_labels(self, pr_number: int, labels: List[str]) -> bool: - """Add labels to a PR, ensuring they exist with appropriate colors. + """Add missing labels to a PR. Args: pr_number: PR number @@ -447,34 +677,6 @@ def _add_pr_labels(self, pr_number: int, labels: List[str]) -> bool: if not self.repository or not labels: return False - # Color mapping for severity labels (matching emoji colors) - label_colors = { - 'security: critical': ('D73A4A', 'Critical security vulnerabilities'), - 'security: high': ('D93F0B', 'High severity security issues'), - 'security: medium': ('FBCA04', 'Medium severity security issues'), - 'security: low': ('E4E4E4', 'Low severity security issues'), - } - - # Ensure labels exist with correct colors - for label in labels: - # Get color and description if this is a known severity label - color_info = label_colors.get(label) - if color_info: - color, description = color_info - self._ensure_label_exists_with_color(label, color, description) - # For custom label names, use a default color - elif ':' in label: - # Try to infer severity from label name - label_lower = label.lower() - if 'critical' in label_lower: - self._ensure_label_exists_with_color(label, 'D73A4A', 'Critical security vulnerabilities') - elif 'high' in label_lower: - self._ensure_label_exists_with_color(label, 'D93F0B', 'High severity security issues') - elif 'medium' in label_lower: - self._ensure_label_exists_with_color(label, 'FBCA04', 'Medium severity security issues') - elif 'low' in label_lower: - self._ensure_label_exists_with_color(label, 'E4E4E4', 'Low severity security issues') - try: import requests headers = { @@ -490,12 +692,33 @@ def _add_pr_labels(self, pr_number: int, labels: List[str]) -> bool: logger.info('GithubPRNotifier: added labels to PR %s: %s', pr_number, ', '.join(labels)) return True else: - logger.warning('GithubPRNotifier: failed to add labels: %s', resp.status_code) + logger.warning('GithubPRNotifier: failed to add labels: %s %s', resp.status_code, resp.text[:200]) return False except Exception as e: logger.error('GithubPRNotifier: exception adding labels: %s', e) return False + def _reconcile_pr_labels(self, pr_number: int, desired_labels: List[str]) -> bool: + """Reconcile managed severity labels on the PR to match the latest run.""" + managed_labels = set(filter(None, self._managed_pr_label_config().values())) + current_labels = set(self._get_current_pr_label_names(pr_number)) + desired_label_set = set(filter(None, desired_labels)) + + stale_labels = sorted(label for label in current_labels if label in managed_labels and label not in desired_label_set) + labels_to_add = sorted(label for label in desired_label_set if label not in current_labels) + + success = True + for label in stale_labels: + success = self._remove_pr_label(pr_number, label) and success + + if labels_to_add: + self._ensure_pr_labels_exist(labels_to_add) + success = self._add_pr_labels(pr_number, labels_to_add) and success + + if not stale_labels and not labels_to_add: + logger.info('GithubPRNotifier: PR %s severity labels already up to date', pr_number) + return success + def _determine_pr_labels(self, notifications: List[Dict[str, Any]]) -> List[str]: """Determine which labels to add based on notifications. @@ -517,6 +740,7 @@ def _determine_pr_labels(self, notifications: List[Dict[str, Any]]) -> List[str] critical_match = re.search(r'Critical:\s*(\d+)', content) high_match = re.search(r'High:\s*(\d+)', content) medium_match = re.search(r'Medium:\s*(\d+)', content) + low_match = re.search(r'Low:\s*(\d+)', content) if critical_match and int(critical_match.group(1)) > 0: severities_found.add('critical') @@ -524,6 +748,8 @@ def _determine_pr_labels(self, notifications: List[Dict[str, Any]]) -> List[str] severities_found.add('high') if medium_match and int(medium_match.group(1)) > 0: severities_found.add('medium') + if low_match and int(low_match.group(1)) > 0: + severities_found.add('low') # Map severities to label names (using configurable labels) labels = [] @@ -536,5 +762,8 @@ def _determine_pr_labels(self, notifications: List[Dict[str, Any]]) -> List[str] elif 'medium' in severities_found: label_name = self.config.get('pr_label_medium', 'security: medium') labels.append(label_name) + elif 'low' in severities_found: + label_name = self.config.get('pr_label_low', 'security: low') + labels.append(label_name) return labels \ No newline at end of file diff --git a/socket_basics/core/notification/manager.py b/socket_basics/core/notification/manager.py index d6e8dcc..7063f06 100644 --- a/socket_basics/core/notification/manager.py +++ b/socket_basics/core/notification/manager.py @@ -510,6 +510,15 @@ def _alert_group(alert: Dict[str, Any], comp: Dict[str, Any]) -> str: notifier_data = per_notifier_notifications[notification_key] notifier_facts['notifications'] = notifier_data logger.debug('Using pre-formatted data for notifier %s: %s items', notifier_name, len(notifier_data) if isinstance(notifier_data, list) else 1) + elif notification_key == 'github_pr': + # GitHub PR label reconciliation still needs to run on "all clear" + # reruns where there are no current notification sections. + notifier_facts['notifications'] = [] + logger.debug( + 'No pre-formatted data found for notifier %s (key: %s); passing empty notifications for label reconciliation', + notifier_name, + notification_key, + ) else: # No pre-formatted data available - skip this notifier to avoid sending wrong format logger.debug('No pre-formatted data found for notifier %s (key: %s), skipping to avoid format mismatch', notifier_name, notification_key) diff --git a/socket_basics/notifications.yaml b/socket_basics/notifications.yaml index a6eec60..02c2e3d 100644 --- a/socket_basics/notifications.yaml +++ b/socket_basics/notifications.yaml @@ -144,6 +144,12 @@ notifiers: type: str default: "security: medium" description: "Label name for medium severity findings" + - name: pr_label_low + option: --pr-label-low + env_variable: INPUT_PR_LABEL_LOW + type: str + default: "security: low" + description: "Label name for low severity findings" msteams: module_path: "socket_basics.core.notification.ms_teams_notifier" diff --git a/socket_basics/socket_basics.py b/socket_basics/socket_basics.py index a7f7f04..b10707d 100644 --- a/socket_basics/socket_basics.py +++ b/socket_basics/socket_basics.py @@ -54,6 +54,18 @@ logger = logging.getLogger(__name__) +def count_blocking_alerts(results: Dict[str, Any]) -> int: + """Count alerts that should fail the run.""" + blocking_alerts = 0 + for comp in results.get('components', []): + for alert in comp.get('alerts', []): + if (alert.get('action') or '').strip().lower() == 'ignore': + continue + if alert.get('severity') in ['high', 'critical']: + blocking_alerts += 1 + return blocking_alerts + + class SecurityScanner: """Main security scanning orchestrator using dynamic connectors""" @@ -456,11 +468,7 @@ def main(): logger.info(f"Total alerts: {total_alerts}") # Exit with non-zero code if high/critical issues found - high_critical_alerts = 0 - for comp in results.get('components', []): - for alert in comp.get('alerts', []): - if alert.get('severity') in ['high', 'critical']: - high_critical_alerts += 1 + high_critical_alerts = count_blocking_alerts(results) exit_code = 1 if high_critical_alerts > 0 else 0 if high_critical_alerts > 0: diff --git a/tests/test_github_pr_notifier.py b/tests/test_github_pr_notifier.py new file mode 100644 index 0000000..dac48d0 --- /dev/null +++ b/tests/test_github_pr_notifier.py @@ -0,0 +1,215 @@ +from socket_basics.core.notification.github_pr_notifier import GithubPRNotifier + + +def _notification(summary: str) -> dict: + return {'title': 'Socket SAST JavaScript', 'content': summary} + + +def test_determine_pr_labels_prefers_highest_current_severity(): + notifier = GithubPRNotifier( + { + 'repository': 'SocketDev/socket-basics', + 'pr_label_critical': 'security: critical', + 'pr_label_high': 'security: high', + 'pr_label_medium': 'security: medium', + 'pr_label_low': 'security: low', + } + ) + + labels = notifier._determine_pr_labels( + [_notification('Critical: 0 | High: 1 | Medium: 2 | Low: 3')] + ) + + assert labels == ['security: high'] + + +def test_determine_pr_labels_supports_low_severity(): + notifier = GithubPRNotifier( + { + 'repository': 'SocketDev/socket-basics', + 'pr_label_low': 'security: low', + } + ) + + labels = notifier._determine_pr_labels( + [_notification('Critical: 0 | High: 0 | Medium: 0 | Low: 2')] + ) + + assert labels == ['security: low'] + + +def test_reconcile_pr_labels_replaces_stale_managed_severity(monkeypatch): + notifier = GithubPRNotifier( + { + 'repository': 'SocketDev/socket-basics', + 'pr_label_critical': 'security: critical', + 'pr_label_high': 'security: high', + 'pr_label_medium': 'security: medium', + 'pr_label_low': 'security: low', + } + ) + + removed: list[str] = [] + added: list[str] = [] + ensured: list[str] = [] + + monkeypatch.setattr(notifier, '_get_current_pr_label_names', lambda pr_number: ['security: critical', 'team: backend']) + monkeypatch.setattr(notifier, '_remove_pr_label', lambda pr_number, label: removed.append(label) or True) + monkeypatch.setattr(notifier, '_ensure_pr_labels_exist', lambda labels: ensured.extend(labels)) + monkeypatch.setattr(notifier, '_add_pr_labels', lambda pr_number, labels: added.extend(labels) or True) + + success = notifier._reconcile_pr_labels(123, ['security: medium']) + + assert success is True + assert removed == ['security: critical'] + assert ensured == ['security: medium'] + assert added == ['security: medium'] + + +def test_reconcile_pr_labels_clears_managed_labels_when_none_desired(monkeypatch): + notifier = GithubPRNotifier( + { + 'repository': 'SocketDev/socket-basics', + 'pr_label_critical': 'security: critical', + 'pr_label_high': 'security: high', + 'pr_label_medium': 'security: medium', + 'pr_label_low': 'security: low', + } + ) + + removed: list[str] = [] + monkeypatch.setattr(notifier, '_get_current_pr_label_names', lambda pr_number: ['security: high', 'docs']) + monkeypatch.setattr(notifier, '_remove_pr_label', lambda pr_number, label: removed.append(label) or True) + monkeypatch.setattr(notifier, '_ensure_pr_labels_exist', lambda labels: (_ for _ in ()).throw(AssertionError('should not ensure labels'))) + monkeypatch.setattr(notifier, '_add_pr_labels', lambda pr_number, labels: (_ for _ in ()).throw(AssertionError('should not add labels'))) + + success = notifier._reconcile_pr_labels(123, []) + + assert success is True + assert removed == ['security: high'] + + +def test_notify_reconciles_labels_even_when_notifications_are_empty(monkeypatch): + notifier = GithubPRNotifier( + { + 'repository': 'SocketDev/socket-basics', + 'pr_labels_enabled': True, + } + ) + + reconciled: list[tuple[int, list[str]]] = [] + monkeypatch.setattr(notifier, '_get_pr_number', lambda: 123) + monkeypatch.setattr(notifier, '_reconcile_pr_labels', lambda pr_number, labels: reconciled.append((pr_number, labels)) or True) + + notifier.notify({'notifications': []}) + + assert reconciled == [(123, [])] + + +def test_notify_rewrites_existing_section_to_all_clear_when_notifications_are_empty(monkeypatch): + notifier = GithubPRNotifier( + { + 'repository': 'SocketDev/socket-basics', + 'pr_labels_enabled': True, + } + ) + + comment_body = """ +## Socket SAST JavaScript + +### Summary +🟡 Medium: 1 +""" + updated_bodies: list[str] = [] + + monkeypatch.setattr(notifier, '_get_pr_number', lambda: 123) + monkeypatch.setattr(notifier, '_reconcile_pr_labels', lambda pr_number, labels: True) + monkeypatch.setattr(notifier, '_get_pr_comments', lambda pr_number: [{'id': 99, 'body': comment_body}]) + monkeypatch.setattr( + notifier, + '_update_comment', + lambda pr_number, comment_id, body: updated_bodies.append(body) or True, + ) + + notifier.notify( + { + 'notifications': [], + 'components': [ + { + 'alerts': [ + { + 'generatedBy': 'opengrep-javascript', + 'subType': 'sast-javascript', + 'action': 'ignore', + } + ] + } + ], + } + ) + + assert len(updated_bodies) == 1 + assert 'Socket Basics found no active findings in the latest run.' in updated_bodies[0] + assert '' in updated_bodies[0] + + +def test_notify_empty_sast_notifications_do_not_rewrite_unrelated_sections(monkeypatch): + notifier = GithubPRNotifier( + { + 'repository': 'SocketDev/socket-basics', + 'pr_labels_enabled': True, + } + ) + + sast_comment = """ +## Socket SAST JavaScript + +### Summary +🟡 Medium: 1 +""" + tier1_comment = """ +## Socket Security Tier 1 + +### Summary +🟠 High: 2 +""" + updated_comments: list[tuple[int, str]] = [] + + monkeypatch.setattr(notifier, '_get_pr_number', lambda: 123) + monkeypatch.setattr(notifier, '_reconcile_pr_labels', lambda pr_number, labels: True) + monkeypatch.setattr( + notifier, + '_get_pr_comments', + lambda pr_number: [ + {'id': 99, 'body': sast_comment}, + {'id': 100, 'body': tier1_comment}, + ], + ) + monkeypatch.setattr( + notifier, + '_update_comment', + lambda pr_number, comment_id, body: updated_comments.append((comment_id, body)) or True, + ) + + notifier.notify( + { + 'notifications': [], + 'components': [ + { + 'alerts': [ + { + 'generatedBy': 'opengrep-javascript', + 'subType': 'sast-javascript', + 'action': 'ignore', + } + ] + } + ], + } + ) + + assert len(updated_comments) == 1 + assert updated_comments[0][0] == 99 + assert '' in updated_comments[0][1] + assert 'Socket Basics found no active findings in the latest run.' in updated_comments[0][1] + assert '' not in updated_comments[0][1] diff --git a/tests/test_notification_manager_github_pr.py b/tests/test_notification_manager_github_pr.py new file mode 100644 index 0000000..cc77309 --- /dev/null +++ b/tests/test_notification_manager_github_pr.py @@ -0,0 +1,22 @@ +from socket_basics.core.notification.manager import NotificationManager + + +class _DummyGithubPrNotifier: + name = "github_pr" + + def __init__(self): + self.payloads = [] + + def notify(self, facts): + self.payloads.append(facts) + + +def test_notify_all_passes_empty_notifications_to_github_pr_for_all_clear(): + notifier = _DummyGithubPrNotifier() + nm = NotificationManager({}, app_config={"repo": "SocketDev/socket-basics"}) + nm.notifiers = [notifier] + + nm.notify_all({"components": [], "notifications": {}}) + + assert len(notifier.payloads) == 1 + assert notifier.payloads[0]["notifications"] == [] diff --git a/tests/test_sast_ignore_overrides.py b/tests/test_sast_ignore_overrides.py new file mode 100644 index 0000000..de36943 --- /dev/null +++ b/tests/test_sast_ignore_overrides.py @@ -0,0 +1,182 @@ +from socket_basics.core.config import ( + Config, + normalize_repo_relative_path, + parse_sast_ignore_overrides, +) +from socket_basics.core.connector.normalizer import _normalize_alert +from socket_basics.core.connector.opengrep import OpenGrepScanner +from socket_basics.socket_basics import count_blocking_alerts + + +class _DummyConnector: + def __init__(self, config: Config): + self.config = config + + +def _build_alert(path: str = 'index.js') -> dict: + return { + 'title': 'js-sql-injection', + 'severity': 'critical', + 'props': { + 'ruleId': 'js-sql-injection', + 'filePath': path, + 'startLine': 14, + 'endLine': 14, + }, + 'location': { + 'path': path, + 'start': 14, + 'end': 14, + }, + } + + +def test_parse_sast_ignore_overrides_supports_rule_and_exact_path(): + parsed = parse_sast_ignore_overrides( + 'js-sql-injection, js-sql-injection:./src/db/query.js' + ) + + assert parsed == [ + {'rule_id': 'js-sql-injection', 'path': None}, + {'rule_id': 'js-sql-injection', 'path': 'src/db/query.js'}, + ] + + +def test_parse_sast_ignore_overrides_skips_glob_paths(caplog): + parsed = parse_sast_ignore_overrides('js-sql-injection:src/**/*.js') + + assert parsed == [] + assert 'glob syntax' in caplog.text + + +def test_normalize_alert_ignores_rule_only_override(): + connector = _DummyConnector(Config({'workspace': '.', 'sast_ignore_overrides': 'js-sql-injection'})) + + alert = _normalize_alert(_build_alert(), connector=connector) + + assert alert['action'] == 'ignore' + assert alert['actionReason'] == 'sast_ignore_override' + + +def test_normalize_alert_ignores_matching_rule_and_path_override(): + connector = _DummyConnector( + Config({'workspace': '.', 'sast_ignore_overrides': 'js-sql-injection:index.js'}) + ) + + alert = _normalize_alert(_build_alert(), connector=connector) + + assert alert['action'] == 'ignore' + assert alert['actionReason'] == 'sast_ignore_override' + + +def test_normalize_alert_accepts_windows_style_override_paths(): + connector = _DummyConnector( + Config({'workspace': '.', 'sast_ignore_overrides': r'js-sql-injection:src\unsafe\demo.js'}) + ) + + alert = _normalize_alert(_build_alert('src/unsafe/demo.js'), connector=connector) + + assert alert['action'] == 'ignore' + assert alert['actionReason'] == 'sast_ignore_override' + + +def test_get_sast_ignore_overrides_warns_when_path_does_not_exist(tmp_path, caplog): + config = Config( + { + 'workspace': str(tmp_path), + 'sast_ignore_overrides': 'js-sql-injection:src/services/credential_sync/api.ts', + } + ) + + parsed = config.get_sast_ignore_overrides() + + assert parsed == [{'rule_id': 'js-sql-injection', 'path': 'src/services/credential_sync/api.ts'}] + assert 'does not exist under workspace' in caplog.text + assert 'will not fall back to rule-only matching' in caplog.text + + +def test_normalize_repo_relative_path_strips_github_actions_workspace_prefix(monkeypatch): + monkeypatch.setenv('GITHUB_WORKSPACE', '/github/workspace') + + assert normalize_repo_relative_path('github/workspace/index.js') == 'index.js' + + +def test_normalize_repo_relative_path_strips_gitlab_workspace_prefix(monkeypatch): + monkeypatch.setenv('CI_PROJECT_DIR', '/builds/acme/sample-repo') + + assert normalize_repo_relative_path('/builds/acme/sample-repo/src/index.js') == 'src/index.js' + + +def test_normalize_repo_relative_path_strips_bitbucket_workspace_prefix(monkeypatch): + monkeypatch.setenv('BITBUCKET_CLONE_DIR', '/opt/atlassian/pipelines/agent/build') + + assert normalize_repo_relative_path('/opt/atlassian/pipelines/agent/build/index.js') == 'index.js' + + +def test_normalize_repo_relative_path_strips_buildkite_workspace_prefix(monkeypatch): + monkeypatch.setenv('BUILDKITE_BUILD_CHECKOUT_PATH', '/var/lib/buildkite-agent/builds/agent-1/org/repo') + + assert normalize_repo_relative_path( + '/var/lib/buildkite-agent/builds/agent-1/org/repo/app/index.js' + ) == 'app/index.js' + + +def test_normalize_alert_strips_github_actions_workspace_prefix(monkeypatch): + monkeypatch.setenv('GITHUB_WORKSPACE', '/github/workspace') + + connector = _DummyConnector( + Config({'workspace': '.', 'sast_ignore_overrides': 'js-sql-injection:index.js'}) + ) + + alert = _normalize_alert( + _build_alert('github/workspace/index.js'), + connector=connector, + ) + + assert alert['action'] == 'ignore' + + +def test_normalize_alert_does_not_ignore_non_matching_path_override(): + connector = _DummyConnector( + Config({'workspace': '.', 'sast_ignore_overrides': 'js-sql-injection:src/index.js'}) + ) + + alert = _normalize_alert(_build_alert(), connector=connector) + + assert alert['action'] == 'error' + assert 'actionReason' not in alert + + +def test_normalize_alert_marks_disabled_rule_ignores(): + connector = _DummyConnector( + Config({'workspace': '.', 'javascript_disabled_rules': 'js-sql-injection'}) + ) + + alert = _normalize_alert(_build_alert('src/services/credential_sync/api.ts'), connector=connector) + + assert alert['action'] == 'ignore' + assert alert['actionReason'] == 'disabled_rule' + + +def test_count_blocking_alerts_skips_ignored_findings(): + results = { + 'components': [ + {'id': 'ignored.js', 'alerts': [{**_build_alert('ignored.js'), 'action': 'ignore'}]}, + {'id': 'active.js', 'alerts': [{**_build_alert('active.js'), 'action': 'error'}]}, + ] + } + + assert count_blocking_alerts(results) == 1 + + +def test_opengrep_notifications_skip_ignored_findings(): + scanner = OpenGrepScanner(Config({'workspace': '.'})) + component = { + 'id': 'index.js', + 'qualifiers': {'scanner': 'opengrep', 'type': 'javascript'}, + 'alerts': [{**_build_alert(), 'action': 'ignore', 'subType': 'sast-javascript'}], + } + + notifications = scanner.generate_notifications([component]) + + assert notifications == {}