# Lab Specification — Module S08: SDLC Gate Harnesses

**Course**: 2A — Building AI Harnesses for Cybersecurity
**Module**: S08 — SDLC Gate Harnesses
**Duration**: 120–150 minutes (four labs, one per sub-section)
**Environment**: Python 3.11+, Pydantic. Semgrep, Gitleaks, Checkov, and Snyk/OWASP Dependency-Check CLIs. A deliberately vulnerable sample repo (provided). An LLM API key for cross-scanner triage. Sample NVD/EPSS/KEV data (provided JSON).

---

## Learning objectives

1. Design a hard-gate vs soft-gate decision matrix per scan type and justify each threshold.
2. Orchestrate SAST, SCA, secrets, and IaC scanners in parallel; aggregate and deduplicate into a unified finding list.
3. Build a trend and risk-scoring system over simulated build history; implement a PR gate from the risk score.
4. Build a CVE triage harness using NVD, EPSS, and CISA KEV to produce a prioritized remediation queue.

---

## Phase 1 — Gate Decision Matrix (25 min)

### 1.1 Define the matrix

```python
from pydantic import BaseModel
from typing import Literal

class GateDecision(BaseModel):
    scan_type: Literal["secrets", "sast", "sca", "iac", "license"]
    condition: str           # e.g. "validated + high confidence"
    action: Literal["hard_block", "soft_warn"]
    rationale: str

GATE_MATRIX = [
    GateDecision(scan_type="secrets", condition="validated + high confidence",
                 action="hard_block", rationale="Active breach path"),
    GateDecision(scan_type="sast", condition="critical + high confidence",
                 action="hard_block", rationale="Exploit path ships"),
    GateDecision(scan_type="sca", condition="CISA KEV or EPSS > 0.7",
                 action="hard_block", rationale="Known/likely exploited"),
    GateDecision(scan_type="iac", condition="public exposure (0.0.0.0/0, public S3)",
                 action="hard_block", rationale="Immediate risk"),
    GateDecision(scan_type="sast", condition="medium/low",
                 action="soft_warn", rationale="Surface for review, don't block"),
    GateDecision(scan_type="sca", condition="low EPSS, fix available",
                 action="soft_warn", rationale="Triage and schedule"),
    GateDecision(scan_type="license", condition="unknown/copyleft",
                 action="soft_warn", rationale="Legal review, not security block"),
]

def evaluate_gate(finding: UnifiedFinding, epss: float = 0.0, in_kev: bool = False) -> str:
    """Apply the matrix to a finding; return hard_block or soft_warn."""
    for rule in GATE_MATRIX:
        if matches(finding, rule, epss, in_kev):
            return rule.action
    return "soft_warn"  # default: warn, don't block
```

### 1.2 Document and defend each threshold

For each rule in the matrix, write a one-paragraph rationale defending the gate choice. Specifically address: why does this finding hard-block (immediately dangerous) vs soft-warn (needs attention, not immediate)?

### Deliverable
- [ ] Gate decision matrix implemented with all scan types
- [ ] `evaluate_gate` correctly routes findings to hard_block or soft_warn
- [ ] Each threshold defended with a written rationale

---

## Phase 2 — Multi-Scanner Orchestration (40 min)

### 2.1 Run the four scanners in parallel

```python
import asyncio, subprocess, json

async def run_sast(repo_path: str) -> list[dict]:
    proc = await asyncio.create_subprocess_exec(
        "semgrep", "--config", "auto", "--json", repo_path,
        stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    stdout, _ = await proc.communicate()
    return json.loads(stdout).get("results", [])

async def run_secrets(repo_path: str) -> list[dict]:
    proc = await asyncio.create_subprocess_exec(
        "gitleaks", "detect", "--source", repo_path, "--report-format", "json",
        stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    stdout, _ = await proc.communicate()
    return json.loads(stdout) if stdout.strip() else []

async def run_iac(repo_path: str) -> list[dict]:
    proc = await asyncio.create_subprocess_exec(
        "checkov", "-d", repo_path, "--output", "json",
        stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    stdout, _ = await proc.communicate()
    return json.loads(stdout).get("results", {}).get("failed_checks", [])

async def run_sca(manifest_path: str) -> list[dict]:
    # Snyk or OWASP Dependency-Check
    ...

async def run_all_scanners(repo_path: str, manifest_path: str) -> dict:
    """Run all 4 scanner types concurrently."""
    results = await asyncio.gather(
        run_sast(repo_path), run_sca(manifest_path),
        run_secrets(repo_path), run_iac(repo_path)
    )
    return {"sast": results[0], "sca": results[1], "secrets": results[2], "iac": results[3]}
```

### 2.2 Normalize to the unified schema

```python
class UnifiedFinding(BaseModel):
    scanner: Literal["sast", "sca", "secrets", "iac"]
    tool: str
    severity: Literal["critical", "high", "medium", "low"]
    cwe: str | None = None
    cve: str | None = None
    location: dict
    description: str
    dedup_key: str
    confidence: Literal["high", "medium", "low"]

SEVERITY_MAP = {
    "semgrep": {"ERROR": "high", "WARNING": "medium", "INFO": "low"},
    "snyk": {"critical": "critical", "high": "high", "medium": "medium", "low": "low"},
    "gitleaks": {"high": "critical", "medium": "high", "low": "medium"},
    "checkov": {"FAILED": "high", "PASSED": "low"},
}

def normalize(raw: dict, scanner: str, tool: str) -> UnifiedFinding:
    raw_sev = raw.get("extra", {}).get("severity", raw.get("severity", "medium"))
    severity = SEVERITY_MAP[tool].get(raw_sev, "medium")
    # Build dedup_key from scanner-agnostic identity
    dedup = f"{scanner}:{raw.get('cwe', raw.get('cve', ''))}:{raw.get('path', '')}:{raw.get('start_line', raw.get('line', ''))}"
    return UnifiedFinding(scanner=scanner, tool=tool, severity=severity, ...)
```

### 2.3 Cross-scanner dedup

```python
def dedup_findings(findings: list[UnifiedFinding]) -> list[UnifiedFinding]:
    """Dedup via dedup_key; merge cross-scanner duplicates."""
    seen = {}
    for f in findings:
        if f.dedup_key in seen:
            # Retain highest severity
            if SEV_ORDER[f.severity] > SEV_ORDER[seen[f.dedup_key].severity]:
                seen[f.dedup_key] = f
        else:
            seen[f.dedup_key] = f
    return list(seen.values())
```

### 2.4 Run against the deliberately vulnerable repo

1. Run all four scanners (parallel) against the provided sample repo.
2. Normalize all results to `UnifiedFinding`.
3. Dedup. Count raw vs deduplicated findings.

### Deliverable
- [ ] Four scanners run concurrently (verify parallel timing)
- [ ] All results normalized to the unified schema with severity mapping
- [ ] Cross-scanner dedup collapses duplicates (raw count vs dedup count documented)

---

## Phase 3 — Trend Analysis and Risk Scoring (35 min)

### 3.1 Simulate build history

```python
import random

def simulate_build_history(num_builds: int = 10) -> list[BuildRecord]:
    """Simulate 10 builds with varying finding counts."""
    history = []
    base_counts = {"critical": 2, "high": 5, "medium": 12, "low": 20}
    for i in range(num_builds):
        # Add some drift: rising trend in first half, falling in second
        drift = 1 + (0.05 * (i - num_builds/2))
        counts = {sev: max(0, int(base * drift + random.randint(-2, 2)))
                  for sev, base in base_counts.items()}
        history.append(BuildRecord(
            build_id=f"build-{i}", timestamp=f"2026-07-0{i+1}T10:00:00Z",
            pr_id=f"pr-{100+i}", branch="main",
            finding_counts={"sast": counts, "sca": {}, "secrets": {}, "iac": {}},
            risk_score=0, author=f"dev-{i % 3}"
        ))
    return history
```

### 3.2 Compute risk score per PR

```python
SEVERITY_WEIGHTS = {"critical": 25, "high": 10, "medium": 3, "low": 1}

def compute_risk_score(build: BuildRecord, history: list[BuildRecord]) -> float:
    baseline = average_counts(history[:-1]) if len(history) > 1 else {}
    new_findings = diff_counts(build.finding_counts, baseline)
    score = sum(
        count * SEVERITY_WEIGHTS[sev]
        for scan_type in new_findings.values()
        for sev, count in scan_type.items()
    )
    if trend_is_rising(history, window=10):
        score *= 1.2
    return min(score, 100.0)

def trend_is_rising(history: list[BuildRecord], window: int = 10) -> bool:
    """Is the total critical+high count rising over the last N builds?"""
    recent = history[-window:]
    totals = [sum(b.finding_counts.get("sast", {}).get(s, 0) for s in ["critical", "high"]) for b in recent]
    return len(totals) >= 2 and totals[-1] > totals[0]
```

### 3.3 Implement the PR gate

```python
def gate_from_score(score: float) -> str:
    if score < 30:
        return "PASS"
    elif score <= 60:
        return "REQUIRES_SECURITY_REVIEW"
    else:
        return "BLOCKED"
```

### 3.4 Run and visualize

1. Simulate 10 builds.
2. Compute the risk score for each.
3. Plot the trend chart (finding counts + risk score over builds).
4. For a simulated current PR, compute the score and apply the gate.

### Deliverable
- [ ] 10-build history simulated with visible trend
- [ ] Risk score computed per build with severity weights + trend modifier
- [ ] PR gate implemented (pass / requires review / blocked)
- [ ] Trend chart produced (finding counts + risk score over time)

---

## Phase 4 — CVE Triage Harness (35 min)

### 4.1 Ingest the CVE feed and match dependencies

```python
def match_cves(manifest: dict, nvd_feed: list[dict]) -> list[dict]:
    """Match dependencies in the manifest against the NVD feed."""
    matches = []
    for pkg, version in manifest.items():
        for cve in nvd_feed:
            if affects(cve, pkg, version):
                matches.append({**cve, "matched_package": pkg, "matched_version": version})
    return matches
```

### 4.2 Score with EPSS and check CISA KEV

```python
def enrich_with_exploitability(matches: list[dict], epss_data: dict, kev_set: set) -> list[dict]:
    for m in matches:
        cve_id = m["cve_id"]
        m["epss"] = epss_data.get(cve_id, 0.0)
        m["in_kev"] = cve_id in kev_set
    return matches
```

### 4.3 Produce the prioritized remediation queue

```python
def prioritize(findings: list[dict]) -> list[dict]:
    """Sort into the 5-tier priority queue."""
    def tier(f):
        if f["in_kev"]:
            return 0  # CISA KEV → fix in HOURS
        if f["epss"] > 0.7 and f.get("reachable", True):
            return 1  # EPSS > 0.7, reachable → fix in DAYS
        if f["epss"] > 0.7:
            return 2  # EPSS > 0.7, not confirmed → verify reachability
        if f["cvss"] >= 7.0:
            return 3  # High CVSS, low EPSS → backlog
        return 4    # Low CVSS, low EPSS → accept risk / batch-fix

    return sorted(findings, key=lambda f: (tier(f), -f.get("cvss", 0)))
```

### 4.4 Run against the provided data

1. Load the provided `mock-manifest.json` (10 dependencies), `mock-nvd.json` (50 CVEs), `mock-epss.json`, and `mock-kev.json`.
2. Match dependencies to CVEs.
3. Enrich with EPSS and KEV status.
4. Produce the prioritized queue.

### Deliverable
- [ ] Dependencies matched to CVEs from the NVD feed
- [ ] Each match enriched with EPSS score and KEV status
- [ ] Prioritized remediation queue (5 tiers) produced
- [ ] Top 3 items (the "fix this week" list) identified and justified

---

## Stretch goals

1. **Add environment-relevance filtering**: given a reachability report from a SAST tool (which functions are called), filter out CVEs whose vulnerable code path is not reachable. Measure how much the queue shrinks.
2. **Implement bug report intake**: write a parser for a mock HackerOne report JSON. Dedup it against the scanner findings via semantic similarity. Auto-route a P1 report to immediate response.
3. **Add cross-scanner LLM triage**: group related findings (e.g. an SCA vuln and a SAST finding in the same file) and ask the LLM to correlate — upgrade or downgrade severity based on combined context.
