Skip to content

Commit 94126c8

Browse files
committed
Add DevSecOps CI validation workflow
1 parent 21c80a4 commit 94126c8

22 files changed

Lines changed: 990 additions & 1 deletion
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
name: Heimdall DevSecOps Validation
2+
3+
on:
4+
pull_request:
5+
push:
6+
workflow_dispatch:
7+
8+
jobs:
9+
validate:
10+
runs-on: ubuntu-latest
11+
permissions:
12+
contents: read
13+
pull-requests: write
14+
15+
steps:
16+
- name: Checkout repository
17+
uses: actions/checkout@v4
18+
19+
- name: Set up Python
20+
uses: actions/setup-python@v5
21+
with:
22+
python-version: "3.11"
23+
24+
- name: Install dependencies
25+
run: |
26+
python -m pip install --upgrade pip
27+
pip install -r requirements.txt
28+
pip install semgrep
29+
30+
- name: Run Semgrep
31+
run: |
32+
semgrep scan --config auto --json --output semgrep-results.json || true
33+
34+
- name: Run Heimdall policy validation
35+
run: |
36+
python -m heimdall.cli validate --semgrep semgrep-results.json --config heimdall.yml --output reports/
37+
38+
- name: Post PR comment when available
39+
if: always()
40+
env:
41+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
42+
run: |
43+
python scripts/post_pr_comment.py || true
44+
45+
- name: Upload Heimdall reports
46+
if: always()
47+
uses: actions/upload-artifact@v4
48+
with:
49+
name: heimdall-devsecops-reports
50+
path: reports/

README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,47 @@ Interpretation guidelines:
155155

156156
The workflow `.github/workflows/heimdall.yml` installs dependencies, runs Semgrep when available, writes Semgrep JSON, executes Heimdall in dry-run mode with the sample dataset, uploads reports as artifacts, and fails only when the full pipeline confirms High or Critical True Positives.
157157

158+
## DevSecOps Integration
159+
160+
Heimdall V2 can run as a CI/CD validation backend for pull requests and pushes. The DevSecOps workflow consumes Semgrep JSON, converts findings into Heimdall alerts, runs the same safety-first validation pipeline, writes CI reports, and exits according to policy.
161+
162+
Local commands:
163+
164+
```bash
165+
python -m heimdall.cli check-config --config heimdall.yml
166+
python -m heimdall.cli validate --semgrep test_data/semgrep-results-sample.json --config heimdall.yml --output reports/
167+
python -m heimdall.cli experiment --dataset data/sample_alerts.jsonl --mode all --output reports/
168+
```
169+
170+
GitHub Actions workflow:
171+
172+
- `.github/workflows/heimdall-devsecops.yml` runs on `push` and `pull_request`.
173+
- Semgrep writes `semgrep-results.json`.
174+
- Heimdall writes `reports/ci_summary.md`, `reports/ci_results.json`, and `reports/ci_results.csv`.
175+
- Reports are uploaded as workflow artifacts.
176+
- `scripts/post_pr_comment.py` can post the Markdown summary to a pull request when `GITHUB_TOKEN` and PR context are available.
177+
178+
Policy behavior:
179+
180+
- Exit code `0` when no confirmed High/Critical True Positive is found.
181+
- Exit code `0` when findings are only False Positive or Needs Review.
182+
- Exit code `1` when a confirmed High/Critical True Positive is found and `heimdall.yml` says to fail.
183+
- Exit code `2` for unsafe or invalid config.
184+
- Exit code `3` for runtime errors.
185+
186+
Needs Review does not fail by default because it indicates missing context, authentication, multi-step state, or safety restrictions rather than confirmed exploitability.
187+
188+
Safety defaults:
189+
190+
- Dry-run is enabled.
191+
- Mock LLM is enabled.
192+
- Active DAST is limited to `security.allowed_targets`.
193+
- Production-looking domains are blocked unless explicitly enabled.
194+
- Destructive payload markers are rejected.
195+
- All DAST attempts are logged.
196+
197+
See `docs/devsecops_integration.md` for the full CI/CD architecture and local vulnerable app instructions.
198+
158199
## Safety Warning
159200

160201
Do not run dynamic validation against production systems unless explicit authorization and target allowlisting are configured. Heimdall V2 defaults to dry-run validation, local targets, non-destructive payloads, request logging, rate limiting, timeouts, and a kill switch.

docs/devsecops_integration.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Heimdall DevSecOps Integration
2+
3+
Heimdall V2 can run as a CI/CD security validation backend. In this mode, Semgrep produces static findings, Heimdall converts those findings into alert objects, the validation pipeline classifies each alert, and the CLI returns a policy-based exit code.
4+
5+
## CI/CD Workflow
6+
7+
```mermaid
8+
flowchart TD
9+
A[Developer opens PR] --> B[GitHub Actions starts]
10+
B --> C[Semgrep scans code]
11+
C --> D[Semgrep JSON saved]
12+
D --> E[Heimdall validates findings]
13+
E --> F[Heimdall classifies TP / FP / Needs Review]
14+
F --> G[Report uploaded]
15+
G --> H[Pipeline passes or fails based on policy]
16+
```
17+
18+
## GitHub Actions Flow
19+
20+
The workflow `.github/workflows/heimdall-devsecops.yml` installs Python dependencies, installs Semgrep, writes `semgrep-results.json`, runs `python -m heimdall.cli validate`, uploads reports, and optionally posts `reports/ci_summary.md` to pull requests.
21+
22+
## Semgrep-To-Heimdall Data Flow
23+
24+
1. Semgrep writes JSON results.
25+
2. `heimdall.semgrep_ingest` preserves rule ID, severity, file path, line number, message, snippet, and CWE metadata where available.
26+
3. Findings are converted into Heimdall `Alert` objects.
27+
4. The existing prompt guard, mock LLM provider, payload generator, DAST executor, response analyzer, and decision engine run in dry-run mode by default.
28+
5. CI reports are written to `reports/`.
29+
30+
## Policy Decision Logic
31+
32+
- Exit code `0`: no confirmed High/Critical True Positive was found.
33+
- Exit code `0`: only False Positive or Needs Review findings exist.
34+
- Exit code `1`: confirmed High/Critical True Positive exists and policy says to fail.
35+
- Exit code `2`: config is invalid or unsafe.
36+
- Exit code `3`: runtime error.
37+
38+
Needs Review does not fail the pipeline by default because it means the system lacks enough context for safe automated validation.
39+
40+
## Safety Model
41+
42+
- Dry-run is enabled by default.
43+
- Mock LLM is enabled by default.
44+
- DAST refuses targets not present in `security.allowed_targets`.
45+
- Production-looking domains are rejected unless `security.allow_external_targets` is explicitly enabled.
46+
- Blocked targets cannot also be allowlisted.
47+
- Every DAST attempt is logged.
48+
- The kill switch stops DAST immediately.
49+
50+
## Local Integration Test
51+
52+
Start the local test app:
53+
54+
```bash
55+
cd test_apps/flask_vulnerable_app
56+
python -m pip install flask
57+
python app.py
58+
```
59+
60+
Run Heimdall with the sample Semgrep output:
61+
62+
```bash
63+
python -m heimdall.cli check-config --config heimdall.yml
64+
python -m heimdall.cli validate --semgrep test_data/semgrep-results-sample.json --config heimdall.yml --output reports/
65+
```
66+
67+
Read `reports/ci_summary.md` for the CI-friendly result.
68+
69+
## Limitations
70+
71+
- The default validation path is conservative and dry-run based.
72+
- Authenticated and multi-step workflows require additional context.
73+
- Real LLM providers should remain behind the structured output validator.
74+
- Production deployment requires audited target allowlists, secret management, and review of policy thresholds.

heimdall.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
security:
2+
dry_run: true
3+
allowed_targets:
4+
- "http://localhost:5000"
5+
- "http://127.0.0.1:5000"
6+
blocked_targets:
7+
- "https://production.example.com"
8+
fail_on_confirmed_high: true
9+
fail_on_confirmed_critical: true
10+
needs_review_does_not_fail: true
11+
12+
dast:
13+
max_requests_per_scan: 50
14+
request_timeout_seconds: 5
15+
16+
llm:
17+
provider: "mock"
18+
use_mock_llm: true
19+
20+
reports:
21+
output_dir: "reports"
22+
23+
semgrep:
24+
output_path: "semgrep-results.json"

heimdall/ci_policy.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from __future__ import annotations
2+
3+
from heimdall.config import HeimdallConfig
4+
from heimdall.evaluation.models import EvaluationResult
5+
6+
7+
def determine_exit_code(results: list[EvaluationResult], config: HeimdallConfig) -> int:
8+
for result in results:
9+
severity = result.severity.lower()
10+
confirmed = result.final_decision == "True Positive" or result.classification == "TP"
11+
if confirmed and severity == "critical" and config.security.fail_on_confirmed_critical:
12+
return 1
13+
if confirmed and severity == "high" and config.security.fail_on_confirmed_high:
14+
return 1
15+
return 0
16+
17+
18+
def policy_summary(results: list[EvaluationResult], config: HeimdallConfig) -> dict:
19+
high_critical = [
20+
result
21+
for result in results
22+
if (result.final_decision == "True Positive" or result.classification == "TP")
23+
and result.severity.lower() in {"high", "critical"}
24+
]
25+
exit_code = determine_exit_code(results, config)
26+
return {
27+
"passed": exit_code == 0,
28+
"exit_code": exit_code,
29+
"high_critical_confirmed": len(high_critical),
30+
"needs_review_does_not_fail": config.security.needs_review_does_not_fail,
31+
}

heimdall/ci_reports.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
from __future__ import annotations
2+
3+
import csv
4+
import json
5+
from dataclasses import asdict
6+
from pathlib import Path
7+
8+
from heimdall.ci_policy import policy_summary
9+
from heimdall.config import HeimdallConfig
10+
from heimdall.evaluation.models import EvaluationResult
11+
12+
13+
def write_ci_reports(output_dir: str | Path, results: list[EvaluationResult], total_semgrep: int, config: HeimdallConfig) -> None:
14+
output = Path(output_dir)
15+
output.mkdir(parents=True, exist_ok=True)
16+
rows = [asdict(result) for result in results]
17+
(output / "ci_results.json").write_text(json.dumps(rows, indent=2, ensure_ascii=False), encoding="utf-8")
18+
_write_csv(output / "ci_results.csv", rows)
19+
_write_markdown(output / "ci_summary.md", results, total_semgrep, config)
20+
21+
22+
def _write_csv(path: Path, rows: list[dict]) -> None:
23+
fields = [
24+
"alert_id",
25+
"severity",
26+
"vulnerability_type",
27+
"prediction",
28+
"classification",
29+
"final_decision",
30+
"evidence",
31+
"recommended_action",
32+
]
33+
with path.open("w", encoding="utf-8", newline="") as handle:
34+
writer = csv.DictWriter(handle, fieldnames=fields)
35+
writer.writeheader()
36+
for row in rows:
37+
writer.writerow({field: _csv_value(row.get(field, "")) for field in fields})
38+
39+
40+
def _write_markdown(path: Path, results: list[EvaluationResult], total_semgrep: int, config: HeimdallConfig) -> None:
41+
confirmed = [row for row in results if row.final_decision == "True Positive" or row.classification == "TP"]
42+
false_positive = [row for row in results if row.final_decision == "False Positive" or row.classification in {"TN", "FN"}]
43+
review = [row for row in results if row.final_decision == "Needs Review" or row.classification == "REVIEW"]
44+
high_critical = [row for row in confirmed if row.severity.lower() in {"high", "critical"}]
45+
policy = policy_summary(results, config)
46+
lines = [
47+
"# Heimdall CI/CD Summary",
48+
"",
49+
f"Pipeline status: {'PASSED' if policy['passed'] else 'FAILED'}",
50+
"",
51+
"| Metric | Count |",
52+
"|---|---:|",
53+
f"| Total Semgrep findings | {total_semgrep} |",
54+
f"| Total validated findings | {len(results)} |",
55+
f"| Confirmed True Positives | {len(confirmed)} |",
56+
f"| False Positives | {len(false_positive)} |",
57+
f"| Needs Review | {len(review)} |",
58+
f"| High/Critical confirmed vulnerabilities | {len(high_critical)} |",
59+
"",
60+
"## Findings",
61+
"",
62+
"| Rule ID | Severity | File | Line | Heimdall Decision | Evidence | Recommended Action |",
63+
"|---|---|---|---:|---|---|---|",
64+
]
65+
for result in results:
66+
metadata = result.metadata or {}
67+
file_path = metadata.get("file_path", "")
68+
line = metadata.get("line_number", "")
69+
lines.append(
70+
"| {rule} | {severity} | {file} | {line} | {decision} | {evidence} | {action} |".format(
71+
rule=_md(result.alert_id),
72+
severity=_md(result.severity),
73+
file=_md(str(file_path)),
74+
line=line,
75+
decision=_md(result.final_decision or result.prediction),
76+
evidence=_md((result.evidence or result.rationale)[:160]),
77+
action=_md((result.recommended_action or "Review finding.")[:160]),
78+
)
79+
)
80+
path.write_text("\n".join(lines), encoding="utf-8")
81+
82+
83+
def _csv_value(value: object) -> object:
84+
if isinstance(value, (dict, list)):
85+
return json.dumps(value, ensure_ascii=False)
86+
return value
87+
88+
89+
def _md(value: str) -> str:
90+
return value.replace("|", "\\|").replace("\n", " ")

0 commit comments

Comments
 (0)