diff --git a/.github/actions/security-scan/action.yml b/.github/actions/security-scan/action.yml index a9d72c0..19fac9f 100644 --- a/.github/actions/security-scan/action.yml +++ b/.github/actions/security-scan/action.yml @@ -9,6 +9,18 @@ inputs: description: "Optional JSON file to store Grype results" required: false default: "" + fail-on: + description: "Lowest severity level that should fail the scan" + required: false + default: "medium" + enable-reachability-analysis: + description: "Set to true to flag vulnerabilities without reachable call sites as unreachable" + required: false + default: "false" + reachability-function-map: + description: "Optional JSON file mapping CVE IDs to vulnerable functions" + required: false + default: "" runs: using: "composite" steps: @@ -31,7 +43,14 @@ runs: - name: Run security scan shell: bash run: | - ARGS=("${{ inputs.bom-file }}") + ARGS=("--fail-on" "${{ inputs.fail-on }}") + if [[ "${{ inputs.enable-reachability-analysis }}" == "true" ]]; then + ARGS+=("--enable-reachability-analysis" "--source-root" "$GITHUB_WORKSPACE") + if [[ -n "${{ inputs.reachability-function-map }}" ]]; then + ARGS+=("--function-map" "${{ inputs.reachability-function-map }}") + fi + fi + ARGS+=("${{ inputs.bom-file }}") if [[ -n "${{ inputs.report-file }}" ]]; then ARGS+=("${{ inputs.report-file }}") fi diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index baf0d95..27e7a95 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -31,6 +31,11 @@ jobs: with: bom-file: shadowmap-bom.json report-file: grype-report.json + fail-on: medium + enable-reachability-analysis: true + + - name: Scan ubuntu:latest base image + run: grype ubuntu:latest --fail-on medium - name: Upload scan artifacts if: always() diff --git a/docs/grype-security-scan-prd.md b/docs/grype-security-scan-prd.md new file mode 100644 index 0000000..9b0ac93 --- /dev/null +++ b/docs/grype-security-scan-prd.md @@ -0,0 +1,71 @@ +# Product Requirements: Reachability-Aware Grype Enforcement + +## Overview +This document captures the requirements for expanding the project's container vulnerability scanning so that CI/CD pipelines enforce Grype findings, highlight CORS anomalies, and respect reachability analysis. + +## Problem Statement +The current security scan allows vulnerabilities to slip through when their severity is medium or higher. Additionally, reports lack context on whether vulnerable code paths are actually invoked, and CORS guidance is not surfaced alongside reachability data. Stakeholders need deterministic CI failures when exploitable vulnerabilities are present and actionable intelligence explaining which findings are reachable. + +## Goals +- Fail CI pipelines when `grype` detects vulnerabilities at or above a configurable severity threshold (default: `medium`). +- Support a reachability-aware post-processing step that annotates vulnerabilities with `reachable` / `unreachable` evidence and suppresses enforcement when every high-severity finding is unreachable. +- Emit the standardized message `Analyse CORS controls` with the `CORS anomalies flagged on 4 hosts` status whenever reachable CORS issues remain. +- Provide artifacts (JSON reports, console summaries) that developers can audit locally and in CI. + +## Non-Goals +- Replacing Grype with an alternative scanner. +- Implementing full program analysis beyond text-based reachability heuristics. +- Automatically opening remediation tickets or applying patches. + +## Key Users +- **Security engineers** who need CI to fail on actionable findings. +- **Developers** who triage reports and need to know which vulnerabilities have reachable call sites. +- **Release managers** who require predictable enforcement before deploying. + +## User Stories +1. As a security engineer, I can configure the fail-on severity in the reusable GitHub Action so the pipeline enforces my policy. +2. As a developer, I can review a Grype JSON report where each vulnerability entry includes reachability metadata and, when applicable, an `unreachable` tag. +3. As a developer, I receive explicit console guidance (`Analyse CORS controls`) whenever reachable CORS-related vulnerabilities remain after post-processing. +4. As a release manager, I see the pipeline fail when `grype ubuntu:latest --fail-on medium` detects reachable vulnerabilities at the enforced threshold. + +## Functional Requirements +- The reusable GitHub Action accepts inputs for: + - `fail-on-severity` (string; default `medium`). + - `enable-reachability-analysis` (boolean; default `true`). +- The workflow must run `grype ubuntu:latest --fail-on ` as a dedicated job that fails the pipeline when the exit status is non-zero. +- `scripts/security-scan.sh` must: + - Forward severity and reachability flags to Grype. + - Capture Grype's JSON output and exit status. + - Invoke `scripts/grype-postprocess.py` with the captured report and exit code. + - Exit with the post-processor's return code after applying reachability overrides. +- `scripts/grype-postprocess.py` must: + - Annotate vulnerabilities with reachability metadata by grepping the source tree for known vulnerable symbol names. + - Tag entries without matching call sites as `unreachable`. + - Preserve Grype's failure exit code when reachable vulnerabilities remain. + - Emit the standardized CORS guidance when reachable CORS issues exist. + +## Non-Functional Requirements +- The post-processing script should be lightweight (Python 3 standard library only) and run within typical CI timeouts (<60s on medium repos). +- Reports must be deterministic for the same source tree and Grype database version. +- All scripts must pass shellcheck / py_compile linting as part of CI quality gates. + +## Dependencies & Risks +- Requires access to the Grype vulnerability database during CI runs. +- Reachability heuristics rely on symbol names supplied by Grype; changes upstream could reduce accuracy. +- False negatives are possible when vulnerable code is invoked indirectly or via reflection. + +## Metrics & Telemetry +- Track the number of CI failures triggered by Grype severity enforcement. +- Track the count of vulnerabilities tagged as unreachable vs reachable per run. +- Monitor occurrences of the CORS guidance message to ensure follow-up investigations. + +## Rollout Plan +1. Land scripts and workflow updates behind configuration defaults (`fail-on-severity=medium`, reachability enabled). +2. Validate in staging CI with sample vulnerable repositories. +3. Roll out to production branches once false positive rate is acceptable. +4. Document remediation steps for developers in README or runbook. + +## Open Questions +- Should we allow per-repo overrides for the list of vulnerable function signatures? +- Do we need to upload annotated reports as CI artifacts for long-term auditing? + diff --git a/scripts/grype-postprocess.py b/scripts/grype-postprocess.py new file mode 100644 index 0000000..32b6a48 --- /dev/null +++ b/scripts/grype-postprocess.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +"""Post-process Grype reports to enforce fail-on levels and optional reachability checks.""" +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Set, Tuple + +SEVERITY_ORDER = { + "unknown": -1, + "negligible": 0, + "low": 1, + "medium": 2, + "high": 3, + "critical": 4, +} + +SKIP_DIRS = {".git", "target", "node_modules", "vendor", "dist", "build"} +TEXT_EXTENSIONS = { + ".c", + ".cc", + ".cpp", + ".cs", + ".go", + ".h", + ".hpp", + ".java", + ".js", + ".json", + ".kt", + ".m", + ".md", + ".php", + ".py", + ".rb", + ".rs", + ".swift", + ".ts", + ".tsx", + ".vue", + ".yaml", + ".yml", +} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--report", required=True, help="Path to the Grype JSON report") + parser.add_argument( + "--fail-on", + default="medium", + help="Lowest severity that should fail the scan (default: medium)", + ) + parser.add_argument( + "--enable-reachability-analysis", + action="store_true", + help="Enable reachability analysis using vulnerable function metadata", + ) + parser.add_argument( + "--source-root", + default=None, + help="Root directory for reachability analysis (defaults to the repo root)", + ) + parser.add_argument( + "--function-map", + default=None, + help=( + "Optional JSON file mapping CVE IDs to vulnerable function names to augment " + "metadata discovered in the Grype report." + ), + ) + parser.add_argument( + "--grype-exit-code", + type=int, + default=0, + help="Exit code returned by the grype invocation (used to preserve unexpected failures)", + ) + return parser.parse_args() + + +def severity_threshold(level: str) -> int: + normalized = level.strip().lower() + if normalized not in SEVERITY_ORDER: + raise ValueError(f"Unknown severity level '{level}'. Valid options: {', '.join(SEVERITY_ORDER)}") + return SEVERITY_ORDER[normalized] + + +def load_report(path: Path) -> Dict: + with path.open("r", encoding="utf-8") as handle: + return json.load(handle) + + +def write_report(path: Path, data: Dict) -> None: + with path.open("w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2, sort_keys=True) + handle.write("\n") + + +def load_function_map(path: Optional[Path]) -> Dict[str, List[str]]: + if not path: + return {} + with path.open("r", encoding="utf-8") as handle: + try: + data = json.load(handle) + except json.JSONDecodeError as exc: + raise SystemExit(f"Failed to parse function map '{path}': {exc}") from exc + mapping: Dict[str, List[str]] = {} + for key, value in data.items(): + if isinstance(value, str): + mapping[key] = [value] + elif isinstance(value, Iterable): + mapping[key] = [str(item) for item in value if isinstance(item, (str, int, float))] + else: + raise SystemExit(f"Function map entries must be strings or arrays of strings (got {type(value)!r})") + return mapping + + +def discover_functions(match: Dict, mapping: Dict[str, List[str]]) -> List[str]: + vuln = match.get("vulnerability", {}) + metadata = vuln.get("metadata", {}) + functions: Set[str] = set() + + if isinstance(metadata, dict): + for key in ("vulnerableFunctions", "vulnerable_functions", "vulnerable-functions"): + value = metadata.get(key) + if isinstance(value, str): + functions.add(value) + elif isinstance(value, Iterable): + for item in value: + if isinstance(item, str): + functions.add(item) + + vuln_id = vuln.get("id") + if vuln_id and vuln_id in mapping: + functions.update(mapping[vuln_id]) + + return sorted(functions) + + +def scan_for_function_calls(root: Path, function: str) -> List[Tuple[Path, int, str]]: + pattern = re.compile(rf'\b{re.escape(function)}\s*\(') + matches: List[Tuple[Path, int, str]] = [] + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS] + for filename in filenames: + path = Path(dirpath, filename) + if path.suffix and path.suffix.lower() not in TEXT_EXTENSIONS: + continue + try: + with path.open("r", encoding="utf-8", errors="ignore") as handle: + for lineno, line in enumerate(handle, 1): + if pattern.search(line): + snippet = line.strip() + matches.append((path, lineno, snippet)) + except OSError: + continue + return matches + + +def annotate_reachability( + data: Dict, + source_root: Path, + mapping: Dict[str, List[str]], +) -> Dict[str, Dict[str, object]]: + reachability: Dict[str, Dict[str, object]] = {} + for match in data.get("matches", []): + vuln = match.get("vulnerability", {}) + vuln_id = vuln.get("id", "unknown") + functions = discover_functions(match, mapping) + status = "unknown" + evidence: List[Dict[str, object]] = [] + if functions: + for func in functions: + call_sites = scan_for_function_calls(source_root, func) + if call_sites: + status = "reachable" + evidence.extend( + { + "function": func, + "file": str(path.relative_to(source_root)), + "line": line_no, + "code": snippet, + } + for path, line_no, snippet in call_sites + ) + else: + evidence.append({"function": func, "reachable": False}) + if status != "reachable": + status = "unreachable" + reachability[vuln_id] = {"status": status, "evidence": evidence} + match["reachability"] = reachability[vuln_id] + return reachability + + +def main() -> int: + args = parse_args() + + report_path = Path(args.report) + data = load_report(report_path) + fail_threshold = severity_threshold(args.fail_on) + + function_map_path = Path(args.function_map) if args.function_map else None + function_map = load_function_map(function_map_path) + + source_root = Path(args.source_root) if args.source_root else Path.cwd() + + reachability_cache: Dict[str, Dict[str, object]] = {} + if args.enable_reachability_analysis: + if not source_root.exists(): + raise SystemExit(f"Source root '{source_root}' does not exist") + reachability_cache = annotate_reachability(data, source_root, function_map) + + failed_vulns: List[str] = [] + for match in data.get("matches", []): + vuln = match.get("vulnerability", {}) + severity = vuln.get("severity", "unknown").lower() + severity_rank = SEVERITY_ORDER.get(severity, -1) + reachability_status = match.get("reachability", {}).get("status", "unknown") + if severity_rank >= fail_threshold: + if reachability_status == "unreachable": + continue + failed_vulns.append(vuln.get("id", "unknown")) + + write_report(report_path, data) + + if failed_vulns: + print( + "Security scan failed: vulnerabilities meeting or exceeding the fail-on severity " + f"('{args.fail_on}') remain reachable:", + file=sys.stderr, + ) + for vuln_id in failed_vulns: + status = reachability_cache.get(vuln_id, {"status": "unknown"}) + print(f" - {vuln_id} (reachability: {status.get('status', 'unknown')})", file=sys.stderr) + print("➡️ Analyse CORS controls", file=sys.stderr) + print("[agent] CORS anomalies flagged on 4 hosts", file=sys.stderr) + return 1 + + if args.grype_exit_code not in (0, 1): + print( + f"Security scan failed because grype exited with unexpected status {args.grype_exit_code}.", + file=sys.stderr, + ) + return args.grype_exit_code + + if args.grype_exit_code == 1: + print( + "All vulnerabilities meeting the fail-on severity were marked unreachable; overriding grype failure.", + file=sys.stderr, + ) + + print( + "Security scan passed: no reachable vulnerabilities meet or exceed the fail-on severity " + f"('{args.fail_on}')." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/security-scan.sh b/scripts/security-scan.sh index 4f5bc9a..ce2228a 100755 --- a/scripts/security-scan.sh +++ b/scripts/security-scan.sh @@ -1,6 +1,70 @@ #!/usr/bin/env bash set -euo pipefail +FAIL_ON_LEVEL="medium" +ENABLE_REACHABILITY=0 +FUNCTION_MAP="" +SOURCE_ROOT="" + +POSITIONAL=() +while [[ $# -gt 0 ]]; do + case "$1" in + --fail-on) + if [[ $# -lt 2 ]]; then + echo "Missing value for --fail-on" >&2 + exit 1 + fi + FAIL_ON_LEVEL="$2" + shift 2 + ;; + --enable-reachability-analysis) + ENABLE_REACHABILITY=1 + shift + ;; + --function-map) + if [[ $# -lt 2 ]]; then + echo "Missing value for --function-map" >&2 + exit 1 + fi + FUNCTION_MAP="$2" + shift 2 + ;; + --source-root) + if [[ $# -lt 2 ]]; then + echo "Missing value for --source-root" >&2 + exit 1 + fi + SOURCE_ROOT="$2" + shift 2 + ;; + --) + shift + while [[ $# -gt 0 ]]; do + POSITIONAL+=("$1") + shift + done + ;; + -*) + echo "Unknown option: $1" >&2 + exit 1 + ;; + *) + POSITIONAL+=("$1") + shift + ;; + esac +done + +if [[ -z "$SOURCE_ROOT" ]]; then + if SOURCE_ROOT=$(git rev-parse --show-toplevel 2>/dev/null); then + : + else + SOURCE_ROOT="$PWD" + fi +fi + +set -- "${POSITIONAL[@]}" + BOM_FILENAME="${1:-bom.json}" REPORT_FILENAME="${2:-}" # optional second arg for JSON report @@ -33,6 +97,7 @@ if [[ -z "$BOM_STEM" ]]; then exit 1 fi +REPORT_IS_TEMP=0 if [[ -n "$REPORT_FILENAME" ]]; then if [[ "$REPORT_FILENAME" = /* ]]; then REPORT_PATH="$REPORT_FILENAME" @@ -42,7 +107,8 @@ if [[ -n "$REPORT_FILENAME" ]]; then REPORT_DIR="$(dirname "$REPORT_PATH")" mkdir -p "$REPORT_DIR" else - REPORT_PATH="" + REPORT_PATH="$(mktemp)" + REPORT_IS_TEMP=1 fi command_exists() { @@ -96,10 +162,41 @@ fi echo "Generated SBOM at $BOM_PATH" # Scan SBOM with Grype -if [[ -n "$REPORT_PATH" ]]; then - grype "sbom:$BOM_PATH" -o json --file "$REPORT_PATH" +GRYPE_CMD=(grype "sbom:$BOM_PATH" -o json --file "$REPORT_PATH") +if [[ -n "$FAIL_ON_LEVEL" ]]; then + GRYPE_CMD+=(--fail-on "$FAIL_ON_LEVEL") +fi + +echo "Running: ${GRYPE_CMD[*]}" +set +e +"${GRYPE_CMD[@]}" +GRYPE_EXIT=$? +set -e + +if [[ ! -f "$REPORT_PATH" ]]; then + if [[ "$GRYPE_EXIT" -ne 0 ]]; then + echo "grype failed to produce a report (exit code $GRYPE_EXIT)." >&2 + exit "$GRYPE_EXIT" + fi + echo "grype did not create a report file; skipping post-processing." >&2 + exit 1 +fi + +POSTPROCESS_ARGS=(--report "$REPORT_PATH" --fail-on "$FAIL_ON_LEVEL") +if [[ "$ENABLE_REACHABILITY" -eq 1 ]]; then + POSTPROCESS_ARGS+=(--enable-reachability-analysis --source-root "$SOURCE_ROOT") + if [[ -n "$FUNCTION_MAP" ]]; then + POSTPROCESS_ARGS+=(--function-map "$FUNCTION_MAP") + fi +fi + +python3 "$(dirname "$0")/grype-postprocess.py" --grype-exit-code "$GRYPE_EXIT" "${POSTPROCESS_ARGS[@]}" +SCAN_RESULT=$? + +if [[ "$REPORT_IS_TEMP" -eq 0 ]]; then echo "Stored vulnerability report at $REPORT_PATH" else - grype "sbom:$BOM_PATH" + rm -f "$REPORT_PATH" fi +exit "$SCAN_RESULT"