Add IaC-SecFix app with Gradio UI and agent backend
Browse files- README.md +58 -107
- app.py +10 -0
- demo_examples/demo_iam_wild.json +152 -0
- demo_examples/demo_k8s_root.json +205 -0
- demo_examples/demo_rds_unencr.json +150 -0
- demo_examples/demo_s3_public.json +154 -0
- demo_examples/demo_sg_open.json +135 -0
- iac_secfix_agents/__init__.py +14 -0
- iac_secfix_agents/agents.py +307 -0
- iac_secfix_agents/cli.py +83 -0
- iac_secfix_agents/config.py +42 -0
- iac_secfix_agents/evaluation.py +84 -0
- iac_secfix_agents/llm.py +162 -0
- iac_secfix_agents/orchestrator.py +133 -0
- iac_secfix_agents/policy.py +91 -0
- iac_secfix_agents/safety.py +60 -0
- iac_secfix_agents/scanners.py +267 -0
- iac_secfix_agents/schemas.py +140 -0
- iac_secfix_space/__init__.py +3 -0
- iac_secfix_space/demo_data.py +23 -0
- iac_secfix_space/hf_inference.py +52 -0
- iac_secfix_space/rendering.py +80 -0
- iac_secfix_space/ui.py +200 -0
- requirements.txt +2 -1
README.md
CHANGED
|
@@ -1,131 +1,82 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
|
| 5 |
-
|
| 6 |
|
| 7 |
-
|
| 8 |
|
| 9 |
-
|
| 10 |
|
| 11 |
-
|
|
|
|
| 12 |
|
| 13 |
-
-
|
| 14 |
-
- every agent boundary uses Pydantic models
|
| 15 |
-
- Checkov and HCL/YAML parsers decide whether patches are valid
|
| 16 |
-
- safety gates block scanner suppression and security weakening
|
| 17 |
-
- validated patches still require human approval by default
|
| 18 |
|
| 19 |
-
##
|
| 20 |
|
| 21 |
-
|
| 22 |
|
| 23 |
-
-
|
| 24 |
-
-
|
| 25 |
-
-
|
| 26 |
-
-
|
| 27 |
|
| 28 |
-
##
|
| 29 |
|
| 30 |
-
|
| 31 |
-
2. `PolicyContextAgent`: maps scanner rule IDs to descriptions and remediation hints.
|
| 32 |
-
3. `PatchAgent`: calls an OpenAI-compatible endpoint and parses a `FixCandidate`.
|
| 33 |
-
4. `ValidationAgent`: parses the fixed file, reruns Checkov, measures unresolved targeted findings, new critical/high findings, and diff size.
|
| 34 |
-
5. `SafetyAgent`: refuses requests or patches that weaken security or suppress scanners.
|
| 35 |
-
6. `ComplianceAgent`: converts validation into compliance deltas.
|
| 36 |
-
7. `HumanApprovalGate`: returns `needs_human_review` unless explicit auto-approval is enabled and validation accepts.
|
| 37 |
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
-
|
| 41 |
|
| 42 |
-
-
|
| 43 |
-
-
|
| 44 |
-
-
|
| 45 |
-
-
|
| 46 |
-
-
|
| 47 |
-
-
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
-
The
|
| 50 |
|
| 51 |
-
|
| 52 |
-
- scanner clean rate: 28.57%
|
| 53 |
-
- targeted policy resolution: 41.76%
|
| 54 |
-
- parse validity: 96.43%
|
| 55 |
-
- unsafe action rate: 0%
|
| 56 |
-
- median latency: 94.33 seconds
|
| 57 |
|
| 58 |
-
|
| 59 |
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
-
``
|
| 63 |
-
cd outputs/iac_secfix_agents
|
| 64 |
-
python -m venv .venv
|
| 65 |
-
source .venv/bin/activate
|
| 66 |
-
pip install -e .
|
| 67 |
-
```
|
| 68 |
|
| 69 |
-
|
| 70 |
|
| 71 |
-
|
| 72 |
-
cp .env.example .env
|
| 73 |
-
```
|
| 74 |
|
| 75 |
-
|
|
|
|
|
|
|
| 76 |
|
| 77 |
-
|
| 78 |
-
export IAC_SECFIX_BASE_URL=http://localhost:8000/v1
|
| 79 |
-
export IAC_SECFIX_API_KEY=abc-123
|
| 80 |
-
export IAC_SECFIX_MODEL=iac-secfix-base
|
| 81 |
-
```
|
| 82 |
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
```bash
|
| 86 |
-
iac-secfix-agents remediate examples/demo_sg_open.tf --iac-type terraform
|
| 87 |
-
```
|
| 88 |
-
|
| 89 |
-
The output is an `ApprovalDecision` JSON object. Valid patches still return `needs_human_review` by default.
|
| 90 |
-
|
| 91 |
-
## Summarize Notebook Results
|
| 92 |
-
|
| 93 |
-
From this folder:
|
| 94 |
-
|
| 95 |
-
```bash
|
| 96 |
-
iac-secfix-agents summarize-results --eval-dir ../../Development/eval
|
| 97 |
-
```
|
| 98 |
-
|
| 99 |
-
Use the path that contains your copied notebook evaluation artifacts.
|
| 100 |
-
|
| 101 |
-
## Hugging Face Submission Flow
|
| 102 |
-
|
| 103 |
-
1. Push `../hf_space_iac_secfix` to your Hugging Face Space.
|
| 104 |
-
2. Keep the Space on CPU Basic for reliable demo playback.
|
| 105 |
-
3. Upload the LoRA adapter folder to a Hugging Face model repo.
|
| 106 |
-
4. Add Space secrets only if you have a live endpoint:
|
| 107 |
-
|
| 108 |
-
```text
|
| 109 |
-
IAC_SECFIX_BASE_URL=https://your-endpoint.example.com/v1
|
| 110 |
-
IAC_SECFIX_API_KEY=...
|
| 111 |
-
IAC_SECFIX_MODEL=iac-secfix-base
|
| 112 |
-
```
|
| 113 |
-
|
| 114 |
-
5. In the submission, describe live fine-tuned inference as optional GPU-backed mode, not required for the CPU demo.
|
| 115 |
-
|
| 116 |
-
## Submission Claim
|
| 117 |
-
|
| 118 |
-
Use this wording:
|
| 119 |
-
|
| 120 |
-
> IaC-SecFix is a scanner-verified multi-agent remediation prototype for Terraform and Kubernetes. It combines typed Pydantic contracts, an OpenAI-compatible Qwen patching agent, deterministic Checkov validation, parser checks, safety gates, retry feedback, and human approval. On the complete held-out benchmark, the strongest defensible system is zero-shot + RAG with 0 unsafe actions. The agent loop shows promising partial improvements and is the architecture for continued development.
|
| 121 |
-
|
| 122 |
-
## Important Files
|
| 123 |
-
|
| 124 |
-
- `src/iac_secfix_agents/orchestrator.py`: deterministic multi-agent loop
|
| 125 |
-
- `src/iac_secfix_agents/agents.py`: agent roles, prompt construction, retry feedback
|
| 126 |
-
- `src/iac_secfix_agents/llm.py`: OpenAI-compatible and Pydantic AI compatibility layer
|
| 127 |
-
- `src/iac_secfix_agents/scanners.py`: Checkov and parser validation
|
| 128 |
-
- `src/iac_secfix_agents/safety.py`: deterministic safety gates
|
| 129 |
-
- `docs/architecture.md`: system design
|
| 130 |
-
- `docs/hackathon_report.md`: benchmark interpretation
|
| 131 |
-
- `docs/huggingface_submission.md`: push and model-hosting checklist
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: IaC-SecFix
|
| 3 |
+
colorFrom: red
|
| 4 |
+
colorTo: green
|
| 5 |
+
sdk: gradio
|
| 6 |
+
sdk_version: 5.0.0
|
| 7 |
+
app_file: app.py
|
| 8 |
+
pinned: false
|
| 9 |
+
license: apache-2.0
|
| 10 |
+
---
|
| 11 |
|
| 12 |
+
# IaC-SecFix
|
| 13 |
|
| 14 |
+
Scanner-verified Infrastructure-as-Code remediation demo for the AMD Developer Hackathon.
|
| 15 |
|
| 16 |
+
This Space is designed to be publishable without access to the AMD 192GB GPU. It defaults to deterministic demo playback from notebook artifacts and can optionally call a live Qwen/vLLM endpoint when GPU access is available.
|
| 17 |
|
| 18 |
+
This Space has two modes:
|
| 19 |
|
| 20 |
+
- `Demo playback`: reliable precomputed scanner artifacts from the notebook run.
|
| 21 |
+
- `Live Qwen endpoint`: calls a public OpenAI-compatible vLLM endpoint serving `Qwen/Qwen3.5-9B`.
|
| 22 |
|
| 23 |
+
The complete benchmark-backed system is B3 zero-shot + RAG. B5 is included as promising partial multi-agent loop evidence.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
+
## Model Access
|
| 26 |
|
| 27 |
+
The fine-tuned LoRA adapter should be published as a separate Hugging Face model repo. The CPU Space does not host Qwen3.5-9B directly. Live inference requires one of:
|
| 28 |
|
| 29 |
+
- a public OpenAI-compatible vLLM endpoint
|
| 30 |
+
- a paid GPU Space with enough VRAM
|
| 31 |
+
- a Hugging Face Inference Endpoint
|
| 32 |
+
- another external GPU service
|
| 33 |
|
| 34 |
+
## Operating Pattern
|
| 35 |
|
| 36 |
+
The UI follows the submission operating pattern:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
+
1. Graph node = one responsibility.
|
| 39 |
+
2. Agent output = typed Pydantic model.
|
| 40 |
+
3. All side effects = tools with validation + approval.
|
| 41 |
+
4. All runs = usage-limited + traced + persisted.
|
| 42 |
|
| 43 |
+
Each run writes a typed artifact under `runs/<run_id>.json` containing:
|
| 44 |
|
| 45 |
+
- request
|
| 46 |
+
- ingest result
|
| 47 |
+
- safety decision
|
| 48 |
+
- policy context
|
| 49 |
+
- tool approvals
|
| 50 |
+
- candidate patch
|
| 51 |
+
- validation result
|
| 52 |
+
- approval decision
|
| 53 |
+
- graph trace
|
| 54 |
|
| 55 |
+
The Space never applies infrastructure changes. Patches remain human-review artifacts.
|
| 56 |
|
| 57 |
+
## Project Structure
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
+
`app.py` is only the Space launcher. The application logic lives in `iac_secfix_space/`:
|
| 60 |
|
| 61 |
+
- `schemas.py`: typed Pydantic contracts for requests, graph state, candidates, validation, approvals, and traces.
|
| 62 |
+
- `agents.py`: narrow Pydantic AI worker wrapper for the live `PatchAgent` typed output.
|
| 63 |
+
- `graph.py`: deterministic control plane that owns routing, joins, fallbacks, approval flow, and persistence.
|
| 64 |
+
- `nodes.py`: one-responsibility graph nodes for ingest, safety, routing, policy context, validation, and approval.
|
| 65 |
+
- `tools.py`: side-effect boundary for demo playback and live Qwen calls, with usage limits and approval records.
|
| 66 |
+
- `persistence.py`: per-run JSON artifacts and usage counters.
|
| 67 |
+
- `rendering.py`: UI-safe report/trace rendering.
|
| 68 |
+
- `ui.py`: Gradio interface composition only.
|
| 69 |
|
| 70 |
+
The graph layer is isolated from the UI and tools so the orchestration can be upgraded to `pydantic_graph.beta` without rewriting the security or validation contracts.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
+
## Required Space Secrets
|
| 73 |
|
| 74 |
+
Set these in the Space Settings page when using live inference:
|
|
|
|
|
|
|
| 75 |
|
| 76 |
+
- `IAC_SECFIX_BASE_URL`: public HTTPS URL ending in `/v1`
|
| 77 |
+
- `IAC_SECFIX_API_KEY`: vLLM API key
|
| 78 |
+
- `IAC_SECFIX_MODEL`: served model name, for example `iac-secfix-base`
|
| 79 |
|
| 80 |
+
If these are missing, the app still works in demo playback mode.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
+
See `docs/architecture.md` for the graph/node/tool contract and `docs/huggingface_deployment_guide.md` for deployment steps.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from iac_secfix_space import build_demo
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
demo = build_demo()
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
if __name__ == "__main__":
|
| 10 |
+
demo.launch()
|
demo_examples/demo_iam_wild.json
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"file_name": "demo_iam_wild.tf",
|
| 3 |
+
"iac_type": "terraform",
|
| 4 |
+
"expected_policy_ids": [
|
| 5 |
+
"CKV_AWS_111",
|
| 6 |
+
"CKV_AWS_40",
|
| 7 |
+
"CKV_AWS_62"
|
| 8 |
+
],
|
| 9 |
+
"expected_present_before": [
|
| 10 |
+
"CKV_AWS_62"
|
| 11 |
+
],
|
| 12 |
+
"expected_missing_before": [
|
| 13 |
+
"CKV_AWS_111",
|
| 14 |
+
"CKV_AWS_40"
|
| 15 |
+
],
|
| 16 |
+
"expected_resolved_policy_ids": [
|
| 17 |
+
"CKV_AWS_111",
|
| 18 |
+
"CKV_AWS_40"
|
| 19 |
+
],
|
| 20 |
+
"expected_unresolved_policy_ids": [
|
| 21 |
+
"CKV_AWS_62"
|
| 22 |
+
],
|
| 23 |
+
"original_file": "resource \"aws_iam_policy\" \"wild\" {\n name = \"iac-secfix-wild\"\n\n policy = jsonencode({\n Version = \"2012-10-17\"\n Statement = [{\n Effect = \"Allow\"\n Action = \"*\"\n Resource = \"*\"\n }]\n })\n}\n",
|
| 24 |
+
"fixed_file": "resource \"aws_iam_policy\" \"wild\" {\n name = \"iac-secfix-wild\"\n\n policy = jsonencode({\n Version = \"2012-10-17\"\n Statement = [{\n Effect = \"Allow\"\n Action = \"*\"\n Resource = \"*\"\n }]\n })\n}",
|
| 25 |
+
"pre_summary": {
|
| 26 |
+
"failed": 9,
|
| 27 |
+
"CRITICAL": 0,
|
| 28 |
+
"HIGH": 0,
|
| 29 |
+
"MEDIUM": 9
|
| 30 |
+
},
|
| 31 |
+
"post_summary": {
|
| 32 |
+
"failed": 9,
|
| 33 |
+
"CRITICAL": 0,
|
| 34 |
+
"HIGH": 0,
|
| 35 |
+
"MEDIUM": 9
|
| 36 |
+
},
|
| 37 |
+
"pre_rule_ids": [
|
| 38 |
+
"CKV2_AWS_40",
|
| 39 |
+
"CKV_AWS_286",
|
| 40 |
+
"CKV_AWS_287",
|
| 41 |
+
"CKV_AWS_288",
|
| 42 |
+
"CKV_AWS_289",
|
| 43 |
+
"CKV_AWS_290",
|
| 44 |
+
"CKV_AWS_355",
|
| 45 |
+
"CKV_AWS_62",
|
| 46 |
+
"CKV_AWS_63"
|
| 47 |
+
],
|
| 48 |
+
"post_rule_ids": [
|
| 49 |
+
"CKV2_AWS_40",
|
| 50 |
+
"CKV_AWS_286",
|
| 51 |
+
"CKV_AWS_287",
|
| 52 |
+
"CKV_AWS_288",
|
| 53 |
+
"CKV_AWS_289",
|
| 54 |
+
"CKV_AWS_290",
|
| 55 |
+
"CKV_AWS_355",
|
| 56 |
+
"CKV_AWS_62",
|
| 57 |
+
"CKV_AWS_63"
|
| 58 |
+
],
|
| 59 |
+
"actual_verdict": "rejected",
|
| 60 |
+
"demo_gate_status": "rejected",
|
| 61 |
+
"demo_outcome": "rejected",
|
| 62 |
+
"verdict": "rejected",
|
| 63 |
+
"latency_s": 33.10580461799691,
|
| 64 |
+
"decision": {
|
| 65 |
+
"action": "rejected",
|
| 66 |
+
"reason": "parse failure, unresolved targeted findings, or over-broad diff",
|
| 67 |
+
"patch": {
|
| 68 |
+
"patch": "",
|
| 69 |
+
"fixed_file": "resource \"aws_iam_policy\" \"wild\" {\n name = \"iac-secfix-wild\"\n\n policy = jsonencode({\n Version = \"2012-10-17\"\n Statement = [{\n Effect = \"Allow\"\n Action = \"*\"\n Resource = \"*\"\n }]\n })\n}",
|
| 70 |
+
"resolved_policy_ids": [],
|
| 71 |
+
"explanation": "All targeted rules require either a concrete fix template or human approval. No safe in-file fix exists without inventing placeholder resources.",
|
| 72 |
+
"verification_commands": [],
|
| 73 |
+
"risk_notes": [],
|
| 74 |
+
"requires_human_approval": true
|
| 75 |
+
},
|
| 76 |
+
"validation": {
|
| 77 |
+
"parse_ok": true,
|
| 78 |
+
"targeted_resolved": [],
|
| 79 |
+
"targeted_unresolved": [
|
| 80 |
+
"CKV2_AWS_40",
|
| 81 |
+
"CKV_AWS_286",
|
| 82 |
+
"CKV_AWS_287",
|
| 83 |
+
"CKV_AWS_288",
|
| 84 |
+
"CKV_AWS_289",
|
| 85 |
+
"CKV_AWS_290",
|
| 86 |
+
"CKV_AWS_355",
|
| 87 |
+
"CKV_AWS_62",
|
| 88 |
+
"CKV_AWS_63"
|
| 89 |
+
],
|
| 90 |
+
"new_findings": [],
|
| 91 |
+
"new_critical_high_count": 0,
|
| 92 |
+
"changed_lines_ratio": 0.04,
|
| 93 |
+
"pre_summary": {
|
| 94 |
+
"failed": 9,
|
| 95 |
+
"CRITICAL": 0,
|
| 96 |
+
"HIGH": 0,
|
| 97 |
+
"MEDIUM": 9
|
| 98 |
+
},
|
| 99 |
+
"post_summary": {
|
| 100 |
+
"failed": 9,
|
| 101 |
+
"CRITICAL": 0,
|
| 102 |
+
"HIGH": 0,
|
| 103 |
+
"MEDIUM": 9,
|
| 104 |
+
"LOW": 0,
|
| 105 |
+
"INFO": 0
|
| 106 |
+
},
|
| 107 |
+
"verdict": "retry",
|
| 108 |
+
"retry_reason": "parse failure, unresolved targeted findings, or over-broad diff"
|
| 109 |
+
},
|
| 110 |
+
"compliance": null,
|
| 111 |
+
"n_retries": 1
|
| 112 |
+
},
|
| 113 |
+
"validation": {
|
| 114 |
+
"parse_ok": true,
|
| 115 |
+
"targeted_resolved": [],
|
| 116 |
+
"targeted_unresolved": [
|
| 117 |
+
"CKV2_AWS_40",
|
| 118 |
+
"CKV_AWS_286",
|
| 119 |
+
"CKV_AWS_287",
|
| 120 |
+
"CKV_AWS_288",
|
| 121 |
+
"CKV_AWS_289",
|
| 122 |
+
"CKV_AWS_290",
|
| 123 |
+
"CKV_AWS_355",
|
| 124 |
+
"CKV_AWS_62",
|
| 125 |
+
"CKV_AWS_63"
|
| 126 |
+
],
|
| 127 |
+
"new_findings": [],
|
| 128 |
+
"new_critical_high_count": 0,
|
| 129 |
+
"changed_lines_ratio": 0.04,
|
| 130 |
+
"pre_summary": {
|
| 131 |
+
"failed": 9,
|
| 132 |
+
"CRITICAL": 0,
|
| 133 |
+
"HIGH": 0,
|
| 134 |
+
"MEDIUM": 9
|
| 135 |
+
},
|
| 136 |
+
"post_summary": {
|
| 137 |
+
"failed": 9,
|
| 138 |
+
"CRITICAL": 0,
|
| 139 |
+
"HIGH": 0,
|
| 140 |
+
"MEDIUM": 9,
|
| 141 |
+
"LOW": 0,
|
| 142 |
+
"INFO": 0
|
| 143 |
+
},
|
| 144 |
+
"verdict": "retry",
|
| 145 |
+
"retry_reason": "parse failure, unresolved targeted findings, or over-broad diff"
|
| 146 |
+
},
|
| 147 |
+
"paths": {
|
| 148 |
+
"original": "demo/original/demo_iam_wild.tf",
|
| 149 |
+
"fixed": "demo/fixed/demo_iam_wild.tf",
|
| 150 |
+
"precomputed": "demo/precomputed/demo_iam_wild.json"
|
| 151 |
+
}
|
| 152 |
+
}
|
demo_examples/demo_k8s_root.json
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"file_name": "demo_k8s_root.yaml",
|
| 3 |
+
"iac_type": "kubernetes",
|
| 4 |
+
"expected_policy_ids": [
|
| 5 |
+
"CKV_K8S_20",
|
| 6 |
+
"CKV_K8S_22",
|
| 7 |
+
"CKV_K8S_28",
|
| 8 |
+
"CKV_K8S_8"
|
| 9 |
+
],
|
| 10 |
+
"expected_present_before": [
|
| 11 |
+
"CKV_K8S_20",
|
| 12 |
+
"CKV_K8S_22",
|
| 13 |
+
"CKV_K8S_28",
|
| 14 |
+
"CKV_K8S_8"
|
| 15 |
+
],
|
| 16 |
+
"expected_missing_before": [],
|
| 17 |
+
"expected_resolved_policy_ids": [
|
| 18 |
+
"CKV_K8S_20",
|
| 19 |
+
"CKV_K8S_22",
|
| 20 |
+
"CKV_K8S_28",
|
| 21 |
+
"CKV_K8S_8"
|
| 22 |
+
],
|
| 23 |
+
"expected_unresolved_policy_ids": [],
|
| 24 |
+
"original_file": "apiVersion: v1\nkind: Pod\nmetadata:\n name: iac-secfix-root\nspec:\n containers:\n - name: app\n image: nginx\n securityContext:\n privileged: true\n runAsUser: 0\n",
|
| 25 |
+
"fixed_file": "apiVersion: v1\nkind: Pod\nmetadata:\n name: iac-secfix-root\n namespace: app\nspec:\n containers:\n - name: app\n image: nginx@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890\n securityContext:\n runAsNonRoot: true\n runAsUser: 10001\n allowPrivilegeEscalation: false\n capabilities:\n drop:\n - NET_RAW\n readOnlyRootFilesystem: true\n seccompProfile:\n type: RuntimeDefault\n resources:\n requests:\n cpu: \"100m\"\n memory: \"128Mi\"\n limits:\n cpu: \"500m\"\n memory: \"512Mi\"\n livenessProbe:\n httpGet:\n path: /healthz\n port: 8080\n initialDelaySeconds: 10\n readinessProbe:\n httpGet:\n path: /ready\n port: 8080\n",
|
| 26 |
+
"pre_summary": {
|
| 27 |
+
"failed": 20,
|
| 28 |
+
"CRITICAL": 0,
|
| 29 |
+
"HIGH": 0,
|
| 30 |
+
"MEDIUM": 20
|
| 31 |
+
},
|
| 32 |
+
"post_summary": {
|
| 33 |
+
"failed": 4,
|
| 34 |
+
"CRITICAL": 0,
|
| 35 |
+
"HIGH": 0,
|
| 36 |
+
"MEDIUM": 4
|
| 37 |
+
},
|
| 38 |
+
"pre_rule_ids": [
|
| 39 |
+
"CKV2_K8S_6",
|
| 40 |
+
"CKV_K8S_10",
|
| 41 |
+
"CKV_K8S_11",
|
| 42 |
+
"CKV_K8S_12",
|
| 43 |
+
"CKV_K8S_13",
|
| 44 |
+
"CKV_K8S_14",
|
| 45 |
+
"CKV_K8S_16",
|
| 46 |
+
"CKV_K8S_20",
|
| 47 |
+
"CKV_K8S_21",
|
| 48 |
+
"CKV_K8S_22",
|
| 49 |
+
"CKV_K8S_23",
|
| 50 |
+
"CKV_K8S_28",
|
| 51 |
+
"CKV_K8S_29",
|
| 52 |
+
"CKV_K8S_31",
|
| 53 |
+
"CKV_K8S_37",
|
| 54 |
+
"CKV_K8S_38",
|
| 55 |
+
"CKV_K8S_40",
|
| 56 |
+
"CKV_K8S_43",
|
| 57 |
+
"CKV_K8S_8",
|
| 58 |
+
"CKV_K8S_9"
|
| 59 |
+
],
|
| 60 |
+
"post_rule_ids": [
|
| 61 |
+
"CKV2_K8S_6",
|
| 62 |
+
"CKV_K8S_29",
|
| 63 |
+
"CKV_K8S_37",
|
| 64 |
+
"CKV_K8S_38"
|
| 65 |
+
],
|
| 66 |
+
"actual_verdict": "rejected",
|
| 67 |
+
"demo_gate_status": "rejected",
|
| 68 |
+
"demo_outcome": "rejected",
|
| 69 |
+
"verdict": "rejected",
|
| 70 |
+
"latency_s": 97.44769946900487,
|
| 71 |
+
"decision": {
|
| 72 |
+
"action": "rejected",
|
| 73 |
+
"reason": "parse failure, unresolved targeted findings, or over-broad diff",
|
| 74 |
+
"patch": {
|
| 75 |
+
"patch": "--- a/pod.yaml\n+++ b/pod.yaml\n@@ -1,11 +1,27 @@\n apiVersion: v1\n kind: Pod\n metadata:\n name: iac-secfix-root\n+ namespace: app\n spec:\n containers:\n - name: app\n image: nginx@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890\n+ securityContext:\n+ runAsNonRoot: true\n+ runAsUser: 10001\n+ allowPrivilegeEscalation: false\n+ capabilities:\n+ drop:\n+ - NET_RAW\n+ readOnlyRootFilesystem: true\n+ seccompProfile:\n+ type: RuntimeDefault\n+ resources:\n+ requests:\n+ cpu: \"100m\"\n+ memory: \"128Mi\"\n+ limits:\n+ cpu: \"500m\"\n+ memory: \"512Mi\"\n+ livenessProbe:\n+ httpGet:\n+ path: /healthz\n+ port: 8080\n+ initialDelaySeconds: 10\n+ readinessProbe:\n+ httpGet:\n+ path: /ready\n+ port: 8080\n",
|
| 76 |
+
"fixed_file": "apiVersion: v1\nkind: Pod\nmetadata:\n name: iac-secfix-root\n namespace: app\nspec:\n containers:\n - name: app\n image: nginx@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890\n securityContext:\n runAsNonRoot: true\n runAsUser: 10001\n allowPrivilegeEscalation: false\n capabilities:\n drop:\n - NET_RAW\n readOnlyRootFilesystem: true\n seccompProfile:\n type: RuntimeDefault\n resources:\n requests:\n cpu: \"100m\"\n memory: \"128Mi\"\n limits:\n cpu: \"500m\"\n memory: \"512Mi\"\n livenessProbe:\n httpGet:\n path: /healthz\n port: 8080\n initialDelaySeconds: 10\n readinessProbe:\n httpGet:\n path: /ready\n port: 8080\n",
|
| 77 |
+
"resolved_policy_ids": [
|
| 78 |
+
"CKV_K8S_40",
|
| 79 |
+
"CKV_K8S_21",
|
| 80 |
+
"CKV_K8S_43",
|
| 81 |
+
"CKV_K8S_29",
|
| 82 |
+
"CKV_K8S_28",
|
| 83 |
+
"CKV_K8S_20",
|
| 84 |
+
"CKV_K8S_11",
|
| 85 |
+
"CKV_K8S_13",
|
| 86 |
+
"CKV_K8S_14",
|
| 87 |
+
"CKV_K8S_23",
|
| 88 |
+
"CKV_K8S_8",
|
| 89 |
+
"CKV_K8S_31",
|
| 90 |
+
"CKV_K8S_12",
|
| 91 |
+
"CKV_K8S_16",
|
| 92 |
+
"CKV_K8S_22",
|
| 93 |
+
"CKV_K8S_10",
|
| 94 |
+
"CKV_K8S_9"
|
| 95 |
+
],
|
| 96 |
+
"explanation": "Applied minimal in-file fixes for all targeted rules: set namespace, pinned image with digest, added pod-level and container-level securityContext, added resource requests/limits, added liveness/readiness probes, dropped NET_RAW, set readOnlyRootFilesystem, set seccompProfile, set allowPrivilegeEscalation, set runAsNonRoot and runAsUser, added CPU/memory requests/limits.",
|
| 97 |
+
"verification_commands": [
|
| 98 |
+
"checkov -f fixed_file.yaml --summary"
|
| 99 |
+
],
|
| 100 |
+
"risk_notes": [
|
| 101 |
+
"CKV_K8S_37 and CKV_K8S_38 not applicable as no hostPath volumes or service account tokens are used in the original file."
|
| 102 |
+
],
|
| 103 |
+
"requires_human_approval": false
|
| 104 |
+
},
|
| 105 |
+
"validation": {
|
| 106 |
+
"parse_ok": true,
|
| 107 |
+
"targeted_resolved": [
|
| 108 |
+
"CKV_K8S_10",
|
| 109 |
+
"CKV_K8S_11",
|
| 110 |
+
"CKV_K8S_12",
|
| 111 |
+
"CKV_K8S_13",
|
| 112 |
+
"CKV_K8S_14",
|
| 113 |
+
"CKV_K8S_16",
|
| 114 |
+
"CKV_K8S_20",
|
| 115 |
+
"CKV_K8S_21",
|
| 116 |
+
"CKV_K8S_22",
|
| 117 |
+
"CKV_K8S_23",
|
| 118 |
+
"CKV_K8S_28",
|
| 119 |
+
"CKV_K8S_31",
|
| 120 |
+
"CKV_K8S_40",
|
| 121 |
+
"CKV_K8S_43",
|
| 122 |
+
"CKV_K8S_8",
|
| 123 |
+
"CKV_K8S_9"
|
| 124 |
+
],
|
| 125 |
+
"targeted_unresolved": [
|
| 126 |
+
"CKV2_K8S_6",
|
| 127 |
+
"CKV_K8S_29",
|
| 128 |
+
"CKV_K8S_37",
|
| 129 |
+
"CKV_K8S_38"
|
| 130 |
+
],
|
| 131 |
+
"new_findings": [],
|
| 132 |
+
"new_critical_high_count": 0,
|
| 133 |
+
"changed_lines_ratio": 0.6,
|
| 134 |
+
"pre_summary": {
|
| 135 |
+
"failed": 20,
|
| 136 |
+
"CRITICAL": 0,
|
| 137 |
+
"HIGH": 0,
|
| 138 |
+
"MEDIUM": 20
|
| 139 |
+
},
|
| 140 |
+
"post_summary": {
|
| 141 |
+
"failed": 4,
|
| 142 |
+
"CRITICAL": 0,
|
| 143 |
+
"HIGH": 0,
|
| 144 |
+
"MEDIUM": 4,
|
| 145 |
+
"LOW": 0,
|
| 146 |
+
"INFO": 0
|
| 147 |
+
},
|
| 148 |
+
"verdict": "retry",
|
| 149 |
+
"retry_reason": "parse failure, unresolved targeted findings, or over-broad diff"
|
| 150 |
+
},
|
| 151 |
+
"compliance": null,
|
| 152 |
+
"n_retries": 1
|
| 153 |
+
},
|
| 154 |
+
"validation": {
|
| 155 |
+
"parse_ok": true,
|
| 156 |
+
"targeted_resolved": [
|
| 157 |
+
"CKV_K8S_10",
|
| 158 |
+
"CKV_K8S_11",
|
| 159 |
+
"CKV_K8S_12",
|
| 160 |
+
"CKV_K8S_13",
|
| 161 |
+
"CKV_K8S_14",
|
| 162 |
+
"CKV_K8S_16",
|
| 163 |
+
"CKV_K8S_20",
|
| 164 |
+
"CKV_K8S_21",
|
| 165 |
+
"CKV_K8S_22",
|
| 166 |
+
"CKV_K8S_23",
|
| 167 |
+
"CKV_K8S_28",
|
| 168 |
+
"CKV_K8S_31",
|
| 169 |
+
"CKV_K8S_40",
|
| 170 |
+
"CKV_K8S_43",
|
| 171 |
+
"CKV_K8S_8",
|
| 172 |
+
"CKV_K8S_9"
|
| 173 |
+
],
|
| 174 |
+
"targeted_unresolved": [
|
| 175 |
+
"CKV2_K8S_6",
|
| 176 |
+
"CKV_K8S_29",
|
| 177 |
+
"CKV_K8S_37",
|
| 178 |
+
"CKV_K8S_38"
|
| 179 |
+
],
|
| 180 |
+
"new_findings": [],
|
| 181 |
+
"new_critical_high_count": 0,
|
| 182 |
+
"changed_lines_ratio": 0.6,
|
| 183 |
+
"pre_summary": {
|
| 184 |
+
"failed": 20,
|
| 185 |
+
"CRITICAL": 0,
|
| 186 |
+
"HIGH": 0,
|
| 187 |
+
"MEDIUM": 20
|
| 188 |
+
},
|
| 189 |
+
"post_summary": {
|
| 190 |
+
"failed": 4,
|
| 191 |
+
"CRITICAL": 0,
|
| 192 |
+
"HIGH": 0,
|
| 193 |
+
"MEDIUM": 4,
|
| 194 |
+
"LOW": 0,
|
| 195 |
+
"INFO": 0
|
| 196 |
+
},
|
| 197 |
+
"verdict": "retry",
|
| 198 |
+
"retry_reason": "parse failure, unresolved targeted findings, or over-broad diff"
|
| 199 |
+
},
|
| 200 |
+
"paths": {
|
| 201 |
+
"original": "demo/original/demo_k8s_root.yaml",
|
| 202 |
+
"fixed": "demo/fixed/demo_k8s_root.yaml",
|
| 203 |
+
"precomputed": "demo/precomputed/demo_k8s_root.json"
|
| 204 |
+
}
|
| 205 |
+
}
|
demo_examples/demo_rds_unencr.json
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"file_name": "demo_rds_unencr.tf",
|
| 3 |
+
"iac_type": "terraform",
|
| 4 |
+
"expected_policy_ids": [
|
| 5 |
+
"CKV_AWS_118",
|
| 6 |
+
"CKV_AWS_16",
|
| 7 |
+
"CKV_AWS_17"
|
| 8 |
+
],
|
| 9 |
+
"expected_present_before": [
|
| 10 |
+
"CKV_AWS_118",
|
| 11 |
+
"CKV_AWS_16",
|
| 12 |
+
"CKV_AWS_17"
|
| 13 |
+
],
|
| 14 |
+
"expected_missing_before": [],
|
| 15 |
+
"expected_resolved_policy_ids": [],
|
| 16 |
+
"expected_unresolved_policy_ids": [
|
| 17 |
+
"CKV_AWS_118",
|
| 18 |
+
"CKV_AWS_16",
|
| 19 |
+
"CKV_AWS_17"
|
| 20 |
+
],
|
| 21 |
+
"original_file": "resource \"aws_db_instance\" \"db\" {\n identifier = \"iac-secfix-db\"\n allocated_storage = 20\n engine = \"mysql\"\n instance_class = \"db.t3.micro\"\n username = \"admin\"\n password = \"Password123456!\"\n publicly_accessible = true\n storage_encrypted = false\n skip_final_snapshot = true\n}\n",
|
| 22 |
+
"fixed_file": "resource \"aws_db_instance\" \"db\" {\n identifier = \"iac-secfix-db\"\n allocated_storage = 20\n engine = \"mysql\"\n instance_class = \"db.t3.micro\"\n username = \"admin\"\n password = \"Password123456!\"\n publicly_accessible = true\n storage_encrypted = false\n skip_final_snapshot = true\n}",
|
| 23 |
+
"pre_summary": {
|
| 24 |
+
"failed": 9,
|
| 25 |
+
"CRITICAL": 0,
|
| 26 |
+
"HIGH": 0,
|
| 27 |
+
"MEDIUM": 9
|
| 28 |
+
},
|
| 29 |
+
"post_summary": {
|
| 30 |
+
"failed": 9,
|
| 31 |
+
"CRITICAL": 0,
|
| 32 |
+
"HIGH": 0,
|
| 33 |
+
"MEDIUM": 9
|
| 34 |
+
},
|
| 35 |
+
"pre_rule_ids": [
|
| 36 |
+
"CKV2_AWS_60",
|
| 37 |
+
"CKV_AWS_118",
|
| 38 |
+
"CKV_AWS_129",
|
| 39 |
+
"CKV_AWS_157",
|
| 40 |
+
"CKV_AWS_16",
|
| 41 |
+
"CKV_AWS_161",
|
| 42 |
+
"CKV_AWS_17",
|
| 43 |
+
"CKV_AWS_226",
|
| 44 |
+
"CKV_AWS_293"
|
| 45 |
+
],
|
| 46 |
+
"post_rule_ids": [
|
| 47 |
+
"CKV2_AWS_60",
|
| 48 |
+
"CKV_AWS_118",
|
| 49 |
+
"CKV_AWS_129",
|
| 50 |
+
"CKV_AWS_157",
|
| 51 |
+
"CKV_AWS_16",
|
| 52 |
+
"CKV_AWS_161",
|
| 53 |
+
"CKV_AWS_17",
|
| 54 |
+
"CKV_AWS_226",
|
| 55 |
+
"CKV_AWS_293"
|
| 56 |
+
],
|
| 57 |
+
"actual_verdict": "rejected",
|
| 58 |
+
"demo_gate_status": "rejected",
|
| 59 |
+
"demo_outcome": "rejected",
|
| 60 |
+
"verdict": "rejected",
|
| 61 |
+
"latency_s": 40.018114939011866,
|
| 62 |
+
"decision": {
|
| 63 |
+
"action": "rejected",
|
| 64 |
+
"reason": "parse failure, unresolved targeted findings, or over-broad diff",
|
| 65 |
+
"patch": {
|
| 66 |
+
"patch": "",
|
| 67 |
+
"fixed_file": "resource \"aws_db_instance\" \"db\" {\n identifier = \"iac-secfix-db\"\n allocated_storage = 20\n engine = \"mysql\"\n instance_class = \"db.t3.micro\"\n username = \"admin\"\n password = \"Password123456!\"\n publicly_accessible = true\n storage_encrypted = false\n skip_final_snapshot = true\n}",
|
| 68 |
+
"resolved_policy_ids": [],
|
| 69 |
+
"explanation": "No fix templates were applicable to the original file without inventing placeholder resources. All rules require_human_approval=true.",
|
| 70 |
+
"verification_commands": [],
|
| 71 |
+
"risk_notes": [],
|
| 72 |
+
"requires_human_approval": true
|
| 73 |
+
},
|
| 74 |
+
"validation": {
|
| 75 |
+
"parse_ok": true,
|
| 76 |
+
"targeted_resolved": [],
|
| 77 |
+
"targeted_unresolved": [
|
| 78 |
+
"CKV2_AWS_60",
|
| 79 |
+
"CKV_AWS_118",
|
| 80 |
+
"CKV_AWS_129",
|
| 81 |
+
"CKV_AWS_157",
|
| 82 |
+
"CKV_AWS_16",
|
| 83 |
+
"CKV_AWS_161",
|
| 84 |
+
"CKV_AWS_17",
|
| 85 |
+
"CKV_AWS_226",
|
| 86 |
+
"CKV_AWS_293"
|
| 87 |
+
],
|
| 88 |
+
"new_findings": [],
|
| 89 |
+
"new_critical_high_count": 0,
|
| 90 |
+
"changed_lines_ratio": 0.04,
|
| 91 |
+
"pre_summary": {
|
| 92 |
+
"failed": 9,
|
| 93 |
+
"CRITICAL": 0,
|
| 94 |
+
"HIGH": 0,
|
| 95 |
+
"MEDIUM": 9
|
| 96 |
+
},
|
| 97 |
+
"post_summary": {
|
| 98 |
+
"failed": 9,
|
| 99 |
+
"CRITICAL": 0,
|
| 100 |
+
"HIGH": 0,
|
| 101 |
+
"MEDIUM": 9,
|
| 102 |
+
"LOW": 0,
|
| 103 |
+
"INFO": 0
|
| 104 |
+
},
|
| 105 |
+
"verdict": "retry",
|
| 106 |
+
"retry_reason": "parse failure, unresolved targeted findings, or over-broad diff"
|
| 107 |
+
},
|
| 108 |
+
"compliance": null,
|
| 109 |
+
"n_retries": 1
|
| 110 |
+
},
|
| 111 |
+
"validation": {
|
| 112 |
+
"parse_ok": true,
|
| 113 |
+
"targeted_resolved": [],
|
| 114 |
+
"targeted_unresolved": [
|
| 115 |
+
"CKV2_AWS_60",
|
| 116 |
+
"CKV_AWS_118",
|
| 117 |
+
"CKV_AWS_129",
|
| 118 |
+
"CKV_AWS_157",
|
| 119 |
+
"CKV_AWS_16",
|
| 120 |
+
"CKV_AWS_161",
|
| 121 |
+
"CKV_AWS_17",
|
| 122 |
+
"CKV_AWS_226",
|
| 123 |
+
"CKV_AWS_293"
|
| 124 |
+
],
|
| 125 |
+
"new_findings": [],
|
| 126 |
+
"new_critical_high_count": 0,
|
| 127 |
+
"changed_lines_ratio": 0.04,
|
| 128 |
+
"pre_summary": {
|
| 129 |
+
"failed": 9,
|
| 130 |
+
"CRITICAL": 0,
|
| 131 |
+
"HIGH": 0,
|
| 132 |
+
"MEDIUM": 9
|
| 133 |
+
},
|
| 134 |
+
"post_summary": {
|
| 135 |
+
"failed": 9,
|
| 136 |
+
"CRITICAL": 0,
|
| 137 |
+
"HIGH": 0,
|
| 138 |
+
"MEDIUM": 9,
|
| 139 |
+
"LOW": 0,
|
| 140 |
+
"INFO": 0
|
| 141 |
+
},
|
| 142 |
+
"verdict": "retry",
|
| 143 |
+
"retry_reason": "parse failure, unresolved targeted findings, or over-broad diff"
|
| 144 |
+
},
|
| 145 |
+
"paths": {
|
| 146 |
+
"original": "demo/original/demo_rds_unencr.tf",
|
| 147 |
+
"fixed": "demo/fixed/demo_rds_unencr.tf",
|
| 148 |
+
"precomputed": "demo/precomputed/demo_rds_unencr.json"
|
| 149 |
+
}
|
| 150 |
+
}
|
demo_examples/demo_s3_public.json
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"file_name": "demo_s3_public.tf",
|
| 3 |
+
"iac_type": "terraform",
|
| 4 |
+
"expected_policy_ids": [
|
| 5 |
+
"CKV_AWS_144",
|
| 6 |
+
"CKV_AWS_18",
|
| 7 |
+
"CKV_AWS_19",
|
| 8 |
+
"CKV_AWS_21",
|
| 9 |
+
"CKV_AWS_53"
|
| 10 |
+
],
|
| 11 |
+
"expected_present_before": [
|
| 12 |
+
"CKV_AWS_144",
|
| 13 |
+
"CKV_AWS_18",
|
| 14 |
+
"CKV_AWS_21"
|
| 15 |
+
],
|
| 16 |
+
"expected_missing_before": [
|
| 17 |
+
"CKV_AWS_19",
|
| 18 |
+
"CKV_AWS_53"
|
| 19 |
+
],
|
| 20 |
+
"expected_resolved_policy_ids": [
|
| 21 |
+
"CKV_AWS_19",
|
| 22 |
+
"CKV_AWS_53"
|
| 23 |
+
],
|
| 24 |
+
"expected_unresolved_policy_ids": [
|
| 25 |
+
"CKV_AWS_144",
|
| 26 |
+
"CKV_AWS_18",
|
| 27 |
+
"CKV_AWS_21"
|
| 28 |
+
],
|
| 29 |
+
"original_file": "resource \"aws_s3_bucket\" \"data\" {\n bucket = \"iac-secfix-public-demo\"\n acl = \"public-read\"\n}\n",
|
| 30 |
+
"fixed_file": "resource \"aws_s3_bucket\" \"data\" {\n bucket = \"iac-secfix-public-demo\"\n acl = \"public-read\"\n}",
|
| 31 |
+
"pre_summary": {
|
| 32 |
+
"failed": 8,
|
| 33 |
+
"CRITICAL": 0,
|
| 34 |
+
"HIGH": 0,
|
| 35 |
+
"MEDIUM": 8
|
| 36 |
+
},
|
| 37 |
+
"post_summary": {
|
| 38 |
+
"failed": 8,
|
| 39 |
+
"CRITICAL": 0,
|
| 40 |
+
"HIGH": 0,
|
| 41 |
+
"MEDIUM": 8
|
| 42 |
+
},
|
| 43 |
+
"pre_rule_ids": [
|
| 44 |
+
"CKV2_AWS_6",
|
| 45 |
+
"CKV2_AWS_61",
|
| 46 |
+
"CKV2_AWS_62",
|
| 47 |
+
"CKV_AWS_144",
|
| 48 |
+
"CKV_AWS_145",
|
| 49 |
+
"CKV_AWS_18",
|
| 50 |
+
"CKV_AWS_20",
|
| 51 |
+
"CKV_AWS_21"
|
| 52 |
+
],
|
| 53 |
+
"post_rule_ids": [
|
| 54 |
+
"CKV2_AWS_6",
|
| 55 |
+
"CKV2_AWS_61",
|
| 56 |
+
"CKV2_AWS_62",
|
| 57 |
+
"CKV_AWS_144",
|
| 58 |
+
"CKV_AWS_145",
|
| 59 |
+
"CKV_AWS_18",
|
| 60 |
+
"CKV_AWS_20",
|
| 61 |
+
"CKV_AWS_21"
|
| 62 |
+
],
|
| 63 |
+
"actual_verdict": "rejected",
|
| 64 |
+
"demo_gate_status": "rejected",
|
| 65 |
+
"demo_outcome": "rejected",
|
| 66 |
+
"verdict": "rejected",
|
| 67 |
+
"latency_s": 27.028045281986124,
|
| 68 |
+
"decision": {
|
| 69 |
+
"action": "rejected",
|
| 70 |
+
"reason": "parse failure, unresolved targeted findings, or over-broad diff",
|
| 71 |
+
"patch": {
|
| 72 |
+
"patch": "",
|
| 73 |
+
"fixed_file": "resource \"aws_s3_bucket\" \"data\" {\n bucket = \"iac-secfix-public-demo\"\n acl = \"public-read\"\n}",
|
| 74 |
+
"resolved_policy_ids": [],
|
| 75 |
+
"explanation": "No fix templates were applicable to the original file without inventing placeholder resources. All targeted rules require external dependencies (IAM roles, KMS keys, logging buckets, replication destinations) that do not exist in the file. Setting requires_human_approval=true.",
|
| 76 |
+
"verification_commands": [],
|
| 77 |
+
"risk_notes": [],
|
| 78 |
+
"requires_human_approval": true
|
| 79 |
+
},
|
| 80 |
+
"validation": {
|
| 81 |
+
"parse_ok": true,
|
| 82 |
+
"targeted_resolved": [],
|
| 83 |
+
"targeted_unresolved": [
|
| 84 |
+
"CKV2_AWS_6",
|
| 85 |
+
"CKV2_AWS_61",
|
| 86 |
+
"CKV2_AWS_62",
|
| 87 |
+
"CKV_AWS_144",
|
| 88 |
+
"CKV_AWS_145",
|
| 89 |
+
"CKV_AWS_18",
|
| 90 |
+
"CKV_AWS_20",
|
| 91 |
+
"CKV_AWS_21"
|
| 92 |
+
],
|
| 93 |
+
"new_findings": [],
|
| 94 |
+
"new_critical_high_count": 0,
|
| 95 |
+
"changed_lines_ratio": 0.04,
|
| 96 |
+
"pre_summary": {
|
| 97 |
+
"failed": 8,
|
| 98 |
+
"CRITICAL": 0,
|
| 99 |
+
"HIGH": 0,
|
| 100 |
+
"MEDIUM": 8
|
| 101 |
+
},
|
| 102 |
+
"post_summary": {
|
| 103 |
+
"failed": 8,
|
| 104 |
+
"CRITICAL": 0,
|
| 105 |
+
"HIGH": 0,
|
| 106 |
+
"MEDIUM": 8,
|
| 107 |
+
"LOW": 0,
|
| 108 |
+
"INFO": 0
|
| 109 |
+
},
|
| 110 |
+
"verdict": "retry",
|
| 111 |
+
"retry_reason": "parse failure, unresolved targeted findings, or over-broad diff"
|
| 112 |
+
},
|
| 113 |
+
"compliance": null,
|
| 114 |
+
"n_retries": 1
|
| 115 |
+
},
|
| 116 |
+
"validation": {
|
| 117 |
+
"parse_ok": true,
|
| 118 |
+
"targeted_resolved": [],
|
| 119 |
+
"targeted_unresolved": [
|
| 120 |
+
"CKV2_AWS_6",
|
| 121 |
+
"CKV2_AWS_61",
|
| 122 |
+
"CKV2_AWS_62",
|
| 123 |
+
"CKV_AWS_144",
|
| 124 |
+
"CKV_AWS_145",
|
| 125 |
+
"CKV_AWS_18",
|
| 126 |
+
"CKV_AWS_20",
|
| 127 |
+
"CKV_AWS_21"
|
| 128 |
+
],
|
| 129 |
+
"new_findings": [],
|
| 130 |
+
"new_critical_high_count": 0,
|
| 131 |
+
"changed_lines_ratio": 0.04,
|
| 132 |
+
"pre_summary": {
|
| 133 |
+
"failed": 8,
|
| 134 |
+
"CRITICAL": 0,
|
| 135 |
+
"HIGH": 0,
|
| 136 |
+
"MEDIUM": 8
|
| 137 |
+
},
|
| 138 |
+
"post_summary": {
|
| 139 |
+
"failed": 8,
|
| 140 |
+
"CRITICAL": 0,
|
| 141 |
+
"HIGH": 0,
|
| 142 |
+
"MEDIUM": 8,
|
| 143 |
+
"LOW": 0,
|
| 144 |
+
"INFO": 0
|
| 145 |
+
},
|
| 146 |
+
"verdict": "retry",
|
| 147 |
+
"retry_reason": "parse failure, unresolved targeted findings, or over-broad diff"
|
| 148 |
+
},
|
| 149 |
+
"paths": {
|
| 150 |
+
"original": "demo/original/demo_s3_public.tf",
|
| 151 |
+
"fixed": "demo/fixed/demo_s3_public.tf",
|
| 152 |
+
"precomputed": "demo/precomputed/demo_s3_public.json"
|
| 153 |
+
}
|
| 154 |
+
}
|
demo_examples/demo_sg_open.json
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"file_name": "demo_sg_open.tf",
|
| 3 |
+
"iac_type": "terraform",
|
| 4 |
+
"expected_policy_ids": [
|
| 5 |
+
"CKV_AWS_24",
|
| 6 |
+
"CKV_AWS_25",
|
| 7 |
+
"CKV_AWS_260"
|
| 8 |
+
],
|
| 9 |
+
"expected_present_before": [
|
| 10 |
+
"CKV_AWS_24"
|
| 11 |
+
],
|
| 12 |
+
"expected_missing_before": [
|
| 13 |
+
"CKV_AWS_25",
|
| 14 |
+
"CKV_AWS_260"
|
| 15 |
+
],
|
| 16 |
+
"expected_resolved_policy_ids": [
|
| 17 |
+
"CKV_AWS_24",
|
| 18 |
+
"CKV_AWS_25",
|
| 19 |
+
"CKV_AWS_260"
|
| 20 |
+
],
|
| 21 |
+
"expected_unresolved_policy_ids": [],
|
| 22 |
+
"original_file": "resource \"aws_security_group\" \"open\" {\n name = \"iac-secfix-open\"\n\n ingress {\n from_port = 22\n to_port = 22\n protocol = \"tcp\"\n cidr_blocks = [\"0.0.0.0/0\"]\n }\n}\n",
|
| 23 |
+
"fixed_file": "resource \"aws_security_group\" \"open\" {\n name = \"iac-secfix-open\"\n\n description = \"Production application traffic\"\n\n ingress {\n from_port = 22\n to_port = 22\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/16\"]\n }\n}",
|
| 24 |
+
"pre_summary": {
|
| 25 |
+
"failed": 3,
|
| 26 |
+
"CRITICAL": 0,
|
| 27 |
+
"HIGH": 0,
|
| 28 |
+
"MEDIUM": 3
|
| 29 |
+
},
|
| 30 |
+
"post_summary": {
|
| 31 |
+
"failed": 2,
|
| 32 |
+
"CRITICAL": 0,
|
| 33 |
+
"HIGH": 0,
|
| 34 |
+
"MEDIUM": 2
|
| 35 |
+
},
|
| 36 |
+
"pre_rule_ids": [
|
| 37 |
+
"CKV2_AWS_5",
|
| 38 |
+
"CKV_AWS_23",
|
| 39 |
+
"CKV_AWS_24"
|
| 40 |
+
],
|
| 41 |
+
"post_rule_ids": [
|
| 42 |
+
"CKV2_AWS_5",
|
| 43 |
+
"CKV_AWS_23"
|
| 44 |
+
],
|
| 45 |
+
"actual_verdict": "rejected",
|
| 46 |
+
"demo_gate_status": "rejected",
|
| 47 |
+
"demo_outcome": "rejected",
|
| 48 |
+
"verdict": "rejected",
|
| 49 |
+
"latency_s": 51.86773505600286,
|
| 50 |
+
"decision": {
|
| 51 |
+
"action": "rejected",
|
| 52 |
+
"reason": "parse failure, unresolved targeted findings, or over-broad diff",
|
| 53 |
+
"patch": {
|
| 54 |
+
"patch": "description = \"Production application traffic\"\n\ncidr_blocks = [\"10.0.0.0/16\"]",
|
| 55 |
+
"fixed_file": "resource \"aws_security_group\" \"open\" {\n name = \"iac-secfix-open\"\n\n description = \"Production application traffic\"\n\n ingress {\n from_port = 22\n to_port = 22\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/16\"]\n }\n}",
|
| 56 |
+
"resolved_policy_ids": [
|
| 57 |
+
"CKV_AWS_24",
|
| 58 |
+
"CKV_AWS_23"
|
| 59 |
+
],
|
| 60 |
+
"explanation": "Added description to security group and changed cidr_blocks from 0.0.0.0/0 to 10.0.0.0/16 for port 22.",
|
| 61 |
+
"verification_commands": [
|
| 62 |
+
"checkov -f fixed_file -r CKV_AWS_24 -r CKV_AWS_23"
|
| 63 |
+
],
|
| 64 |
+
"risk_notes": [
|
| 65 |
+
"CKV2_AWS_5 remains unresolved due to missing attachment"
|
| 66 |
+
],
|
| 67 |
+
"requires_human_approval": true
|
| 68 |
+
},
|
| 69 |
+
"validation": {
|
| 70 |
+
"parse_ok": true,
|
| 71 |
+
"targeted_resolved": [
|
| 72 |
+
"CKV_AWS_24"
|
| 73 |
+
],
|
| 74 |
+
"targeted_unresolved": [
|
| 75 |
+
"CKV2_AWS_5",
|
| 76 |
+
"CKV_AWS_23"
|
| 77 |
+
],
|
| 78 |
+
"new_findings": [],
|
| 79 |
+
"new_critical_high_count": 0,
|
| 80 |
+
"changed_lines_ratio": 0.12,
|
| 81 |
+
"pre_summary": {
|
| 82 |
+
"failed": 3,
|
| 83 |
+
"CRITICAL": 0,
|
| 84 |
+
"HIGH": 0,
|
| 85 |
+
"MEDIUM": 3
|
| 86 |
+
},
|
| 87 |
+
"post_summary": {
|
| 88 |
+
"failed": 2,
|
| 89 |
+
"CRITICAL": 0,
|
| 90 |
+
"HIGH": 0,
|
| 91 |
+
"MEDIUM": 2,
|
| 92 |
+
"LOW": 0,
|
| 93 |
+
"INFO": 0
|
| 94 |
+
},
|
| 95 |
+
"verdict": "retry",
|
| 96 |
+
"retry_reason": "parse failure, unresolved targeted findings, or over-broad diff"
|
| 97 |
+
},
|
| 98 |
+
"compliance": null,
|
| 99 |
+
"n_retries": 1
|
| 100 |
+
},
|
| 101 |
+
"validation": {
|
| 102 |
+
"parse_ok": true,
|
| 103 |
+
"targeted_resolved": [
|
| 104 |
+
"CKV_AWS_24"
|
| 105 |
+
],
|
| 106 |
+
"targeted_unresolved": [
|
| 107 |
+
"CKV2_AWS_5",
|
| 108 |
+
"CKV_AWS_23"
|
| 109 |
+
],
|
| 110 |
+
"new_findings": [],
|
| 111 |
+
"new_critical_high_count": 0,
|
| 112 |
+
"changed_lines_ratio": 0.12,
|
| 113 |
+
"pre_summary": {
|
| 114 |
+
"failed": 3,
|
| 115 |
+
"CRITICAL": 0,
|
| 116 |
+
"HIGH": 0,
|
| 117 |
+
"MEDIUM": 3
|
| 118 |
+
},
|
| 119 |
+
"post_summary": {
|
| 120 |
+
"failed": 2,
|
| 121 |
+
"CRITICAL": 0,
|
| 122 |
+
"HIGH": 0,
|
| 123 |
+
"MEDIUM": 2,
|
| 124 |
+
"LOW": 0,
|
| 125 |
+
"INFO": 0
|
| 126 |
+
},
|
| 127 |
+
"verdict": "retry",
|
| 128 |
+
"retry_reason": "parse failure, unresolved targeted findings, or over-broad diff"
|
| 129 |
+
},
|
| 130 |
+
"paths": {
|
| 131 |
+
"original": "demo/original/demo_sg_open.tf",
|
| 132 |
+
"fixed": "demo/fixed/demo_sg_open.tf",
|
| 133 |
+
"precomputed": "demo/precomputed/demo_sg_open.json"
|
| 134 |
+
}
|
| 135 |
+
}
|
iac_secfix_agents/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Submission-ready IaC remediation multi-agent package."""
|
| 2 |
+
|
| 3 |
+
from .config import RuntimeConfig
|
| 4 |
+
from .orchestrator import RemediationOrchestrator, OrchestratorSettings
|
| 5 |
+
from .schemas import ApprovalDecision, FixCandidate, IngestRequest
|
| 6 |
+
|
| 7 |
+
__all__ = [
|
| 8 |
+
"ApprovalDecision",
|
| 9 |
+
"FixCandidate",
|
| 10 |
+
"IngestRequest",
|
| 11 |
+
"OrchestratorSettings",
|
| 12 |
+
"RemediationOrchestrator",
|
| 13 |
+
"RuntimeConfig",
|
| 14 |
+
]
|
iac_secfix_agents/agents.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from .config import RuntimeConfig
|
| 9 |
+
from .llm import OpenAICompatibleLLM, make_pydantic_agent, make_pydantic_openai_model, parse_fix_candidate
|
| 10 |
+
from .policy import build_rule_hints_md, enrich_findings
|
| 11 |
+
from .schemas import (
|
| 12 |
+
ApprovalDecision,
|
| 13 |
+
ComplianceReport,
|
| 14 |
+
EnrichedFindings,
|
| 15 |
+
Finding,
|
| 16 |
+
FixCandidate,
|
| 17 |
+
IngestRequest,
|
| 18 |
+
ScanBundle,
|
| 19 |
+
ValidationResult,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass(slots=True)
|
| 24 |
+
class PydanticAgentBundle:
|
| 25 |
+
ingest: Any | None = None
|
| 26 |
+
policy_context: Any | None = None
|
| 27 |
+
patch: Any | None = None
|
| 28 |
+
validation: Any | None = None
|
| 29 |
+
compliance: Any | None = None
|
| 30 |
+
approval: Any | None = None
|
| 31 |
+
|
| 32 |
+
@property
|
| 33 |
+
def available(self) -> bool:
|
| 34 |
+
return any(
|
| 35 |
+
agent is not None
|
| 36 |
+
for agent in [
|
| 37 |
+
self.ingest,
|
| 38 |
+
self.policy_context,
|
| 39 |
+
self.patch,
|
| 40 |
+
self.validation,
|
| 41 |
+
self.compliance,
|
| 42 |
+
self.approval,
|
| 43 |
+
]
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def create_pydantic_agent_bundle(config: RuntimeConfig) -> PydanticAgentBundle:
|
| 48 |
+
"""Build PydanticAI agents when the installed API supports it."""
|
| 49 |
+
|
| 50 |
+
model = make_pydantic_openai_model(config)
|
| 51 |
+
return PydanticAgentBundle(
|
| 52 |
+
ingest=make_pydantic_agent(
|
| 53 |
+
model,
|
| 54 |
+
ScanBundle,
|
| 55 |
+
"Identify file type and return scanner bundle. Deterministic scanner tools supply findings.",
|
| 56 |
+
),
|
| 57 |
+
policy_context=make_pydantic_agent(
|
| 58 |
+
model,
|
| 59 |
+
EnrichedFindings,
|
| 60 |
+
"Map scanner findings to policy descriptions. Do not invent policy IDs.",
|
| 61 |
+
),
|
| 62 |
+
patch=make_pydantic_agent(model, FixCandidate, "Return only a valid FixCandidate JSON object."),
|
| 63 |
+
validation=make_pydantic_agent(model, ValidationResult, "Summarize deterministic validation results only."),
|
| 64 |
+
compliance=make_pydantic_agent(model, ComplianceReport, "Compute compliance summary only from scan summaries."),
|
| 65 |
+
approval=make_pydantic_agent(
|
| 66 |
+
model,
|
| 67 |
+
ApprovalDecision,
|
| 68 |
+
"Return needs_human_review unless deterministic validation says accept and auto approval is enabled.",
|
| 69 |
+
),
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def shard_for_context(file_content: str, findings: list[Finding], max_chars: int) -> str:
|
| 74 |
+
if len(file_content) <= max_chars:
|
| 75 |
+
return file_content
|
| 76 |
+
|
| 77 |
+
lines = file_content.splitlines()
|
| 78 |
+
selected: list[str] = ["# Non-targeted blocks omitted for context budget."]
|
| 79 |
+
used_ranges: list[tuple[int, int]] = []
|
| 80 |
+
|
| 81 |
+
for finding in findings:
|
| 82 |
+
start = max((finding.line_start or 1) - 20, 1)
|
| 83 |
+
end = min((finding.line_end or start) + 20, len(lines))
|
| 84 |
+
if any(not (end < old_start or start > old_end) for old_start, old_end in used_ranges):
|
| 85 |
+
continue
|
| 86 |
+
used_ranges.append((start, end))
|
| 87 |
+
selected.append(f"\n# Context lines {start}-{end}")
|
| 88 |
+
selected.extend(lines[start - 1 : end])
|
| 89 |
+
if len("\n".join(selected)) >= max_chars:
|
| 90 |
+
break
|
| 91 |
+
|
| 92 |
+
return "\n".join(selected)[:max_chars]
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def fix_candidate_schema_text() -> str:
|
| 96 |
+
return """
|
| 97 |
+
Return exactly one JSON object:
|
| 98 |
+
{
|
| 99 |
+
"patch": "unified diff string",
|
| 100 |
+
"fixed_file": "complete corrected file content",
|
| 101 |
+
"resolved_policy_ids": ["CKV_..."],
|
| 102 |
+
"explanation": "short summary",
|
| 103 |
+
"verification_commands": ["checkov -f <file> -o json"],
|
| 104 |
+
"risk_notes": ["human-review note if needed"],
|
| 105 |
+
"requires_human_approval": true
|
| 106 |
+
}
|
| 107 |
+
No markdown fences. No prose outside JSON.
|
| 108 |
+
""".strip()
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def build_patch_prompt(
|
| 112 |
+
enriched: EnrichedFindings,
|
| 113 |
+
retry_feedback: str | None = None,
|
| 114 |
+
max_context_chars: int = 18000,
|
| 115 |
+
max_finding_records: int = 24,
|
| 116 |
+
) -> str:
|
| 117 |
+
iac_type = enriched.file.iac_type
|
| 118 |
+
lang = "hcl" if iac_type == "terraform" else "yaml"
|
| 119 |
+
findings_for_json = enriched.findings[:max_finding_records]
|
| 120 |
+
omitted = max(0, len(enriched.findings) - len(findings_for_json))
|
| 121 |
+
|
| 122 |
+
scanner_report_json = json.dumps(
|
| 123 |
+
[finding.model_dump(mode="json") for finding in findings_for_json],
|
| 124 |
+
ensure_ascii=False,
|
| 125 |
+
indent=2,
|
| 126 |
+
)
|
| 127 |
+
rule_ids = [finding.rule_id for finding in enriched.findings]
|
| 128 |
+
original_context = shard_for_context(enriched.file.file_content, list(enriched.findings), max_context_chars)
|
| 129 |
+
|
| 130 |
+
prompt = f"""
|
| 131 |
+
Original file type: {iac_type}
|
| 132 |
+
Original file language: {lang}
|
| 133 |
+
|
| 134 |
+
<original_file>
|
| 135 |
+
{original_context}
|
| 136 |
+
</original_file>
|
| 137 |
+
|
| 138 |
+
<targeted_rule_ids>
|
| 139 |
+
{json.dumps(sorted(set(rule_ids)))}
|
| 140 |
+
</targeted_rule_ids>
|
| 141 |
+
|
| 142 |
+
<scanner_findings_json>
|
| 143 |
+
{scanner_report_json}
|
| 144 |
+
</scanner_findings_json>
|
| 145 |
+
|
| 146 |
+
<scanner_findings_omitted_due_context_budget>
|
| 147 |
+
{omitted}
|
| 148 |
+
</scanner_findings_omitted_due_context_budget>
|
| 149 |
+
|
| 150 |
+
<targeted_rules_with_fix_templates>
|
| 151 |
+
{build_rule_hints_md(rule_ids)}
|
| 152 |
+
</targeted_rules_with_fix_templates>
|
| 153 |
+
|
| 154 |
+
Task:
|
| 155 |
+
Create the smallest safe in-file remediation that resolves as many targeted rule_id values as possible.
|
| 156 |
+
|
| 157 |
+
Hard requirements:
|
| 158 |
+
- Start from the ORIGINAL file. Add or modify only attributes directly needed by the matching fix templates.
|
| 159 |
+
- fixed_file must contain the complete corrected file, including unchanged lines.
|
| 160 |
+
- Do not delete, rename, or reorder resources, modules, providers, containers, or attributes unless the rule requires it.
|
| 161 |
+
- Do not invent placeholder ARNs, account IDs, bucket names, topics, roles, regions, KMS keys, CIDRs, or image tags.
|
| 162 |
+
- If a rule requires an external dependency or unknown business decision, leave it unresolved and set requires_human_approval=true.
|
| 163 |
+
- Do not introduce CRITICAL or HIGH findings.
|
| 164 |
+
- Do not add checkov:skip, nosec, tfsec:ignore, or scanner suppression comments.
|
| 165 |
+
- Before returning, list only rule IDs that your fixed_file actually resolves in resolved_policy_ids.
|
| 166 |
+
|
| 167 |
+
{fix_candidate_schema_text()}
|
| 168 |
+
""".strip()
|
| 169 |
+
|
| 170 |
+
if retry_feedback:
|
| 171 |
+
prompt += f"\n\n<previous_validation_feedback>\n{retry_feedback[:4000]}\n</previous_validation_feedback>"
|
| 172 |
+
|
| 173 |
+
return prompt
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def compress_feedback(result: ValidationResult, attempt: int, max_retries: int) -> str:
|
| 177 |
+
payload = {
|
| 178 |
+
"attempt": attempt + 1,
|
| 179 |
+
"max_retries": max_retries,
|
| 180 |
+
"retry_reason": result.retry_reason,
|
| 181 |
+
"targeted_still_unresolved": result.targeted_unresolved,
|
| 182 |
+
"new_critical_high_count": result.new_critical_high_count,
|
| 183 |
+
"changed_lines_ratio": round(result.changed_lines_ratio, 3),
|
| 184 |
+
"post_scan_summary": result.post_summary,
|
| 185 |
+
}
|
| 186 |
+
feedback = json.dumps(payload, ensure_ascii=False)
|
| 187 |
+
if result.targeted_unresolved:
|
| 188 |
+
feedback += "\n\nUnresolved-rule fix templates:\n"
|
| 189 |
+
feedback += build_rule_hints_md(result.targeted_unresolved)
|
| 190 |
+
feedback += (
|
| 191 |
+
"\n\nRetry instructions:\n"
|
| 192 |
+
"- Start again from the original file, not the previous attempt.\n"
|
| 193 |
+
"- Apply the smallest safe edit for the unresolved rules.\n"
|
| 194 |
+
"- Remove rule IDs from resolved_policy_ids when validation did not resolve them.\n"
|
| 195 |
+
)
|
| 196 |
+
return feedback[:4000]
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
class IngestAgent:
|
| 200 |
+
def run(self, request: IngestRequest, tmp_dir: Path | None = None) -> ScanBundle:
|
| 201 |
+
from .scanners import unified_scan
|
| 202 |
+
|
| 203 |
+
return unified_scan(request, tmp_dir=tmp_dir)
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
class PolicyContextAgent:
|
| 207 |
+
def run(self, bundle: ScanBundle) -> EnrichedFindings:
|
| 208 |
+
return enrich_findings(bundle)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
class PatchAgent:
|
| 212 |
+
def __init__(self, config: RuntimeConfig, log_dir: Path):
|
| 213 |
+
self.llm = OpenAICompatibleLLM(config)
|
| 214 |
+
self.log_dir = log_dir
|
| 215 |
+
self.log_dir.mkdir(parents=True, exist_ok=True)
|
| 216 |
+
|
| 217 |
+
def run(
|
| 218 |
+
self,
|
| 219 |
+
enriched: EnrichedFindings,
|
| 220 |
+
retry_feedback: str | None = None,
|
| 221 |
+
max_context_chars: int = 18000,
|
| 222 |
+
) -> tuple[FixCandidate | None, bool, str]:
|
| 223 |
+
prompt = build_patch_prompt(enriched, retry_feedback, max_context_chars=max_context_chars)
|
| 224 |
+
messages = [
|
| 225 |
+
{
|
| 226 |
+
"role": "system",
|
| 227 |
+
"content": (
|
| 228 |
+
"You are IaC-SecFix. Return only valid JSON matching the FixCandidate schema. "
|
| 229 |
+
"Do not include markdown, prose, analysis, or code fences."
|
| 230 |
+
),
|
| 231 |
+
},
|
| 232 |
+
{"role": "user", "content": prompt},
|
| 233 |
+
]
|
| 234 |
+
result = self.llm.chat(messages, json_mode=True)
|
| 235 |
+
self.log_dir.joinpath("last_patch_raw.txt").write_text(result.text[:20000], errors="ignore")
|
| 236 |
+
candidate, schema_valid = parse_fix_candidate(result.text)
|
| 237 |
+
return candidate, schema_valid, result.text
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
class ValidationAgent:
|
| 241 |
+
def run(
|
| 242 |
+
self,
|
| 243 |
+
request: IngestRequest,
|
| 244 |
+
candidate: FixCandidate,
|
| 245 |
+
targeted: list[str],
|
| 246 |
+
tmp_dir: Path | None,
|
| 247 |
+
max_changed_lines_ratio: float,
|
| 248 |
+
) -> ValidationResult:
|
| 249 |
+
from .scanners import validate_candidate
|
| 250 |
+
|
| 251 |
+
return validate_candidate(
|
| 252 |
+
request,
|
| 253 |
+
candidate,
|
| 254 |
+
targeted,
|
| 255 |
+
tmp_dir=tmp_dir,
|
| 256 |
+
max_changed_lines_ratio=max_changed_lines_ratio,
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
class ComplianceAgent:
|
| 261 |
+
def run(self, pre_summary: dict[str, object], result: ValidationResult) -> ComplianceReport:
|
| 262 |
+
before_failed = max(int(pre_summary.get("failed", 0) or 0), len(result.targeted_resolved) + len(result.targeted_unresolved), 1)
|
| 263 |
+
after_failed = int(result.post_summary.get("failed", 0) or 0)
|
| 264 |
+
targeted_total = max(len(result.targeted_resolved) + len(result.targeted_unresolved), 1)
|
| 265 |
+
return ComplianceReport(
|
| 266 |
+
cis_pass_rate_before=max(0.0, 1.0 - before_failed / max(before_failed, 1)),
|
| 267 |
+
cis_pass_rate_after=max(0.0, 1.0 - after_failed / max(before_failed, 1)),
|
| 268 |
+
network_exposure_reduction=1.0 if result.targeted_resolved else 0.0,
|
| 269 |
+
iam_privilege_reduction=1.0 if result.targeted_resolved else 0.0,
|
| 270 |
+
targeted_resolution_rate=len(result.targeted_resolved) / targeted_total,
|
| 271 |
+
scanner_clean=after_failed == 0,
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
class HumanApprovalGate:
|
| 276 |
+
def __init__(self, auto_approve_validated: bool = False):
|
| 277 |
+
self.auto_approve_validated = auto_approve_validated
|
| 278 |
+
|
| 279 |
+
def run(
|
| 280 |
+
self,
|
| 281 |
+
patch: FixCandidate,
|
| 282 |
+
result: ValidationResult,
|
| 283 |
+
compliance: ComplianceReport,
|
| 284 |
+
n_retries: int,
|
| 285 |
+
) -> ApprovalDecision:
|
| 286 |
+
can_auto_approve = (
|
| 287 |
+
self.auto_approve_validated
|
| 288 |
+
and result.verdict == "accept"
|
| 289 |
+
and not patch.requires_human_approval
|
| 290 |
+
)
|
| 291 |
+
if can_auto_approve:
|
| 292 |
+
return ApprovalDecision(
|
| 293 |
+
action="approved",
|
| 294 |
+
reason="Deterministic validation accepted the patch.",
|
| 295 |
+
patch=patch,
|
| 296 |
+
validation=result,
|
| 297 |
+
compliance=compliance,
|
| 298 |
+
n_retries=n_retries,
|
| 299 |
+
)
|
| 300 |
+
return ApprovalDecision(
|
| 301 |
+
action="needs_human_review",
|
| 302 |
+
reason="Validated candidate requires human approval before export.",
|
| 303 |
+
patch=patch,
|
| 304 |
+
validation=result,
|
| 305 |
+
compliance=compliance,
|
| 306 |
+
n_retries=n_retries,
|
| 307 |
+
)
|
iac_secfix_agents/cli.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
from .config import RuntimeConfig
|
| 8 |
+
from .evaluation import build_submission_summary
|
| 9 |
+
from .orchestrator import OrchestratorSettings, RemediationOrchestrator
|
| 10 |
+
from .schemas import IngestRequest
|
| 11 |
+
from .scanners import detect_iac_type
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def cmd_remediate(args: argparse.Namespace) -> int:
|
| 15 |
+
path = Path(args.file)
|
| 16 |
+
content = path.read_text()
|
| 17 |
+
iac_type = args.iac_type or detect_iac_type(path.name, content)
|
| 18 |
+
config = RuntimeConfig.from_env()
|
| 19 |
+
settings = OrchestratorSettings(
|
| 20 |
+
max_retries=args.max_retries,
|
| 21 |
+
auto_approve_validated=args.auto_approve,
|
| 22 |
+
)
|
| 23 |
+
orchestrator = RemediationOrchestrator(config=config, settings=settings)
|
| 24 |
+
decision = orchestrator.remediate(
|
| 25 |
+
IngestRequest(
|
| 26 |
+
file_name=path.name,
|
| 27 |
+
file_content=content,
|
| 28 |
+
iac_type=iac_type,
|
| 29 |
+
user_intent=args.intent,
|
| 30 |
+
)
|
| 31 |
+
)
|
| 32 |
+
print(json.dumps(decision.model_dump(mode="json"), indent=2))
|
| 33 |
+
return 0
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def cmd_summarize_results(args: argparse.Namespace) -> int:
|
| 37 |
+
summary = build_submission_summary(Path(args.eval_dir))
|
| 38 |
+
print(json.dumps(summary, indent=2))
|
| 39 |
+
return 0
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def cmd_self_test(_: argparse.Namespace) -> int:
|
| 43 |
+
request = IngestRequest(
|
| 44 |
+
file_name="example.tf",
|
| 45 |
+
file_content='resource "aws_s3_bucket" "data" { bucket = "demo" }\n',
|
| 46 |
+
iac_type="terraform",
|
| 47 |
+
user_intent="Fix detected IaC security findings.",
|
| 48 |
+
)
|
| 49 |
+
assert request.iac_type == "terraform"
|
| 50 |
+
print("self-test passed: schemas and imports are available")
|
| 51 |
+
return 0
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def build_parser() -> argparse.ArgumentParser:
|
| 55 |
+
parser = argparse.ArgumentParser(prog="iac-secfix-agents")
|
| 56 |
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
| 57 |
+
|
| 58 |
+
remediate = subparsers.add_parser("remediate", help="Run the multi-agent remediation loop for one file.")
|
| 59 |
+
remediate.add_argument("file")
|
| 60 |
+
remediate.add_argument("--iac-type", choices=["terraform", "kubernetes", "dockerfile"])
|
| 61 |
+
remediate.add_argument("--intent", default="Fix the detected IaC security findings.")
|
| 62 |
+
remediate.add_argument("--max-retries", type=int, default=3)
|
| 63 |
+
remediate.add_argument("--auto-approve", action="store_true")
|
| 64 |
+
remediate.set_defaults(func=cmd_remediate)
|
| 65 |
+
|
| 66 |
+
summary = subparsers.add_parser("summarize-results", help="Summarize notebook evaluation artifacts.")
|
| 67 |
+
summary.add_argument("--eval-dir", default="../eval")
|
| 68 |
+
summary.set_defaults(func=cmd_summarize_results)
|
| 69 |
+
|
| 70 |
+
self_test = subparsers.add_parser("self-test", help="Run a lightweight import/schema check.")
|
| 71 |
+
self_test.set_defaults(func=cmd_self_test)
|
| 72 |
+
|
| 73 |
+
return parser
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def main() -> int:
|
| 77 |
+
parser = build_parser()
|
| 78 |
+
args = parser.parse_args()
|
| 79 |
+
return args.func(args)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
if __name__ == "__main__":
|
| 83 |
+
raise SystemExit(main())
|
iac_secfix_agents/config.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@dataclass(slots=True)
|
| 9 |
+
class RuntimeConfig:
|
| 10 |
+
"""Runtime settings for the OpenAI-compatible vLLM endpoint."""
|
| 11 |
+
|
| 12 |
+
base_url: str = "http://localhost:8000/v1"
|
| 13 |
+
api_key: str = "abc-123"
|
| 14 |
+
model: str = "iac-secfix"
|
| 15 |
+
temperature: float = 0.0
|
| 16 |
+
top_p: float = 1.0
|
| 17 |
+
max_tokens: int = 2048
|
| 18 |
+
work_dir: Path = Path("runs")
|
| 19 |
+
|
| 20 |
+
@classmethod
|
| 21 |
+
def from_env(cls) -> "RuntimeConfig":
|
| 22 |
+
return cls(
|
| 23 |
+
base_url=os.getenv("IAC_SECFIX_BASE_URL", cls.base_url),
|
| 24 |
+
api_key=os.getenv("IAC_SECFIX_API_KEY", cls.api_key),
|
| 25 |
+
model=os.getenv("IAC_SECFIX_MODEL", cls.model),
|
| 26 |
+
temperature=float(os.getenv("IAC_SECFIX_TEMPERATURE", str(cls.temperature))),
|
| 27 |
+
top_p=float(os.getenv("IAC_SECFIX_TOP_P", str(cls.top_p))),
|
| 28 |
+
max_tokens=int(os.getenv("IAC_SECFIX_MAX_TOKENS", str(cls.max_tokens))),
|
| 29 |
+
work_dir=Path(os.getenv("IAC_SECFIX_WORK_DIR", str(cls.work_dir))),
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def vllm_extra_body() -> dict[str, object]:
|
| 34 |
+
"""Small vLLM compatibility body.
|
| 35 |
+
|
| 36 |
+
vLLM accepts these fields on OpenAI-compatible chat completions. Keeping this
|
| 37 |
+
helper tiny avoids depending on notebook globals.
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
return {
|
| 41 |
+
"guided_decoding_backend": "outlines",
|
| 42 |
+
}
|
iac_secfix_agents/evaluation.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def read_json(path: Path) -> dict[str, Any]:
|
| 9 |
+
return json.loads(path.read_text()) if path.exists() else {}
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def read_jsonl(path: Path) -> list[dict[str, Any]]:
|
| 13 |
+
if not path.exists():
|
| 14 |
+
return []
|
| 15 |
+
return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def usable_clean_rate(artifacts: list[dict[str, Any]]) -> float:
|
| 19 |
+
if not artifacts:
|
| 20 |
+
return 0.0
|
| 21 |
+
usable = 0
|
| 22 |
+
for artifact in artifacts:
|
| 23 |
+
clean = bool(artifact.get("scanner_clean", False))
|
| 24 |
+
schema_valid = bool(artifact.get("schema_valid", False))
|
| 25 |
+
parse_ok = bool(artifact.get("parse_ok", False))
|
| 26 |
+
quality_issues = artifact.get("quality_issues") or []
|
| 27 |
+
safe = not bool(artifact.get("unsafe_action", False))
|
| 28 |
+
if clean and schema_valid and parse_ok and not quality_issues and safe:
|
| 29 |
+
usable += 1
|
| 30 |
+
return usable / len(artifacts)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def report_row(report: dict[str, Any], artifacts: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
| 34 |
+
artifacts = artifacts or []
|
| 35 |
+
return {
|
| 36 |
+
"system": report.get("system"),
|
| 37 |
+
"n": report.get("n"),
|
| 38 |
+
"scope": report.get("evaluation_scope", "full_benchmark"),
|
| 39 |
+
"evidence_level": report.get("evidence_level"),
|
| 40 |
+
"scanner_clean_rate": report.get("scanner_clean_rate"),
|
| 41 |
+
"usable_clean_rate": usable_clean_rate(artifacts) if artifacts else None,
|
| 42 |
+
"targeted_policy_resolution_rate": report.get("targeted_policy_resolution_rate"),
|
| 43 |
+
"parse_validity_rate": report.get("parse_validity_rate"),
|
| 44 |
+
"pydantic_schema_validity_rate": report.get("pydantic_schema_validity_rate"),
|
| 45 |
+
"unsafe_action_rate": report.get("unsafe_action_rate"),
|
| 46 |
+
"median_latency_s": report.get("median_latency_s"),
|
| 47 |
+
"failure_counts": report.get("failure_counts", {}),
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def build_submission_summary(eval_dir: Path) -> dict[str, Any]:
|
| 52 |
+
report_b1 = read_json(eval_dir / "report_b1.json")
|
| 53 |
+
report_b3 = read_json(eval_dir / "report_b3.json")
|
| 54 |
+
report_b4 = read_json(eval_dir / "report_b4.json") or read_json(eval_dir / "report_b4.partial.json")
|
| 55 |
+
report_b5 = read_json(eval_dir / "report_b5.json") or read_json(eval_dir / "report_b5.partial.json")
|
| 56 |
+
report_final = read_json(eval_dir / "report_final.json")
|
| 57 |
+
|
| 58 |
+
b1_artifacts = read_jsonl(eval_dir / "per_example_b1.jsonl")
|
| 59 |
+
b3_artifacts = read_jsonl(eval_dir / "per_example_b3.jsonl")
|
| 60 |
+
b4_artifacts = read_jsonl(eval_dir / "per_example_b4.jsonl") or read_jsonl(eval_dir / "per_example_b4.partial.jsonl")
|
| 61 |
+
b5_artifacts = read_jsonl(eval_dir / "per_example_b5.jsonl") or read_jsonl(eval_dir / "per_example_b5.partial.jsonl")
|
| 62 |
+
|
| 63 |
+
systems = [
|
| 64 |
+
report_row(report_b1, b1_artifacts),
|
| 65 |
+
report_row(report_b3, b3_artifacts),
|
| 66 |
+
report_row(report_b4, b4_artifacts),
|
| 67 |
+
report_row(report_b5, b5_artifacts),
|
| 68 |
+
]
|
| 69 |
+
|
| 70 |
+
safety_report = read_json(eval_dir / "safety_report.json")
|
| 71 |
+
training_report = read_json(eval_dir / "training_report.json")
|
| 72 |
+
|
| 73 |
+
return {
|
| 74 |
+
"chosen_system": report_final.get("chosen_system", "B3_zero_shot_rag"),
|
| 75 |
+
"recommended_claim": "scanner-verified IaC remediation prototype with deterministic safety and validation gates",
|
| 76 |
+
"systems": systems,
|
| 77 |
+
"safety": safety_report,
|
| 78 |
+
"training": training_report,
|
| 79 |
+
"notes": [
|
| 80 |
+
"B3 is the most defensible final benchmark claim.",
|
| 81 |
+
"B5 shows better partial scanner-clean and targeted-resolution rates, but it is partial and requires human review.",
|
| 82 |
+
"Fine-tuning is infrastructure evidence only unless a full benchmark shows uplift over B3.",
|
| 83 |
+
],
|
| 84 |
+
}
|
iac_secfix_agents/llm.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import inspect
|
| 4 |
+
import json
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from pydantic import ValidationError
|
| 9 |
+
|
| 10 |
+
from .config import RuntimeConfig, vllm_extra_body
|
| 11 |
+
from .schemas import FixCandidate
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def extract_json_object(text: str) -> str | None:
|
| 15 |
+
start = text.find("{")
|
| 16 |
+
if start == -1:
|
| 17 |
+
return None
|
| 18 |
+
|
| 19 |
+
depth = 0
|
| 20 |
+
in_string = False
|
| 21 |
+
escaped = False
|
| 22 |
+
for index in range(start, len(text)):
|
| 23 |
+
char = text[index]
|
| 24 |
+
if in_string:
|
| 25 |
+
if escaped:
|
| 26 |
+
escaped = False
|
| 27 |
+
elif char == "\\":
|
| 28 |
+
escaped = True
|
| 29 |
+
elif char == '"':
|
| 30 |
+
in_string = False
|
| 31 |
+
continue
|
| 32 |
+
if char == '"':
|
| 33 |
+
in_string = True
|
| 34 |
+
elif char == "{":
|
| 35 |
+
depth += 1
|
| 36 |
+
elif char == "}":
|
| 37 |
+
depth -= 1
|
| 38 |
+
if depth == 0:
|
| 39 |
+
return text[start : index + 1]
|
| 40 |
+
return None
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def coerce_fix_candidate(raw: dict[str, Any]) -> dict[str, Any]:
|
| 44 |
+
patch = raw.get("patch") or raw.get("diff") or ""
|
| 45 |
+
fixed_file = raw.get("fixed_file") or raw.get("content") or raw.get("file_content")
|
| 46 |
+
resolved = raw.get("resolved_policy_ids") or raw.get("resolved") or []
|
| 47 |
+
if isinstance(resolved, str):
|
| 48 |
+
resolved = [resolved]
|
| 49 |
+
return {
|
| 50 |
+
"patch": patch,
|
| 51 |
+
"fixed_file": fixed_file,
|
| 52 |
+
"resolved_policy_ids": resolved,
|
| 53 |
+
"explanation": raw.get("explanation") or raw.get("summary") or "",
|
| 54 |
+
"verification_commands": raw.get("verification_commands") or [],
|
| 55 |
+
"risk_notes": raw.get("risk_notes") or [],
|
| 56 |
+
"requires_human_approval": bool(raw.get("requires_human_approval", True)),
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def parse_fix_candidate(text: str) -> tuple[FixCandidate | None, bool]:
|
| 61 |
+
json_text = extract_json_object(text)
|
| 62 |
+
if json_text is None:
|
| 63 |
+
return None, False
|
| 64 |
+
try:
|
| 65 |
+
return FixCandidate.model_validate_json(json_text), True
|
| 66 |
+
except ValidationError:
|
| 67 |
+
try:
|
| 68 |
+
raw = json.loads(json_text)
|
| 69 |
+
if not isinstance(raw, dict):
|
| 70 |
+
return None, False
|
| 71 |
+
return FixCandidate.model_validate(coerce_fix_candidate(raw)), True
|
| 72 |
+
except Exception:
|
| 73 |
+
return None, False
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@dataclass(slots=True)
|
| 77 |
+
class ChatResult:
|
| 78 |
+
text: str
|
| 79 |
+
used_json_mode: bool
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class OpenAICompatibleLLM:
|
| 83 |
+
"""Thin OpenAI-compatible client used by the PatchAgent."""
|
| 84 |
+
|
| 85 |
+
def __init__(self, config: RuntimeConfig):
|
| 86 |
+
from openai import OpenAI
|
| 87 |
+
|
| 88 |
+
self.config = config
|
| 89 |
+
self.client = OpenAI(base_url=config.base_url, api_key=config.api_key)
|
| 90 |
+
|
| 91 |
+
def chat(self, messages: list[dict[str, str]], json_mode: bool = True) -> ChatResult:
|
| 92 |
+
kwargs: dict[str, Any] = {
|
| 93 |
+
"model": self.config.model,
|
| 94 |
+
"messages": messages,
|
| 95 |
+
"temperature": self.config.temperature,
|
| 96 |
+
"top_p": self.config.top_p,
|
| 97 |
+
"max_tokens": self.config.max_tokens,
|
| 98 |
+
"extra_body": vllm_extra_body(),
|
| 99 |
+
}
|
| 100 |
+
if json_mode:
|
| 101 |
+
kwargs["response_format"] = {"type": "json_object"}
|
| 102 |
+
|
| 103 |
+
try:
|
| 104 |
+
completion = self.client.chat.completions.create(**kwargs)
|
| 105 |
+
return ChatResult(completion.choices[0].message.content or "{}", used_json_mode=json_mode)
|
| 106 |
+
except Exception:
|
| 107 |
+
kwargs.pop("response_format", None)
|
| 108 |
+
kwargs.pop("extra_body", None)
|
| 109 |
+
completion = self.client.chat.completions.create(**kwargs)
|
| 110 |
+
return ChatResult(completion.choices[0].message.content or "{}", used_json_mode=False)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def make_pydantic_openai_model(config: RuntimeConfig) -> Any | None:
|
| 114 |
+
"""Create a PydanticAI OpenAI-compatible model when the library is present."""
|
| 115 |
+
|
| 116 |
+
try:
|
| 117 |
+
from pydantic_ai.models.openai import OpenAIChatModel
|
| 118 |
+
from pydantic_ai.providers.openai import OpenAIProvider
|
| 119 |
+
|
| 120 |
+
provider = OpenAIProvider(base_url=config.base_url, api_key=config.api_key)
|
| 121 |
+
return OpenAIChatModel(config.model, provider=provider)
|
| 122 |
+
except Exception:
|
| 123 |
+
pass
|
| 124 |
+
|
| 125 |
+
try:
|
| 126 |
+
from pydantic_ai.models.openai import OpenAIModel
|
| 127 |
+
from pydantic_ai.providers.openai import OpenAIProvider
|
| 128 |
+
|
| 129 |
+
provider = OpenAIProvider(base_url=config.base_url, api_key=config.api_key)
|
| 130 |
+
return OpenAIModel(config.model, provider=provider)
|
| 131 |
+
except Exception:
|
| 132 |
+
return None
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def make_pydantic_agent(model: Any, output_model: type[Any], system_prompt: str) -> Any | None:
|
| 136 |
+
"""Compatibility wrapper for PydanticAI versions.
|
| 137 |
+
|
| 138 |
+
Some environments use `result_type`, newer ones may use `output_type`. The
|
| 139 |
+
notebook hit this exact API mismatch, so the submission code adapts at runtime.
|
| 140 |
+
"""
|
| 141 |
+
|
| 142 |
+
if model is None:
|
| 143 |
+
return None
|
| 144 |
+
try:
|
| 145 |
+
from pydantic_ai import Agent
|
| 146 |
+
|
| 147 |
+
signature = inspect.signature(Agent)
|
| 148 |
+
kwargs: dict[str, Any] = {}
|
| 149 |
+
if "output_type" in signature.parameters:
|
| 150 |
+
kwargs["output_type"] = output_model
|
| 151 |
+
elif "result_type" in signature.parameters:
|
| 152 |
+
kwargs["result_type"] = output_model
|
| 153 |
+
if "instructions" in signature.parameters:
|
| 154 |
+
kwargs["instructions"] = system_prompt
|
| 155 |
+
elif "system_prompt" in signature.parameters:
|
| 156 |
+
kwargs["system_prompt"] = system_prompt
|
| 157 |
+
if "model" in signature.parameters:
|
| 158 |
+
kwargs["model"] = model
|
| 159 |
+
return Agent(**kwargs)
|
| 160 |
+
return Agent(model, **kwargs)
|
| 161 |
+
except Exception:
|
| 162 |
+
return None
|
iac_secfix_agents/orchestrator.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from .agents import (
|
| 7 |
+
ComplianceAgent,
|
| 8 |
+
HumanApprovalGate,
|
| 9 |
+
IngestAgent,
|
| 10 |
+
PatchAgent,
|
| 11 |
+
PolicyContextAgent,
|
| 12 |
+
ValidationAgent,
|
| 13 |
+
compress_feedback,
|
| 14 |
+
create_pydantic_agent_bundle,
|
| 15 |
+
)
|
| 16 |
+
from .config import RuntimeConfig
|
| 17 |
+
from .safety import DeterministicSafetyAgent
|
| 18 |
+
from .schemas import ApprovalDecision, FixCandidate, IngestRequest, ValidationResult
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass(slots=True)
|
| 22 |
+
class OrchestratorSettings:
|
| 23 |
+
max_retries: int = 3
|
| 24 |
+
max_context_chars: int = 18000
|
| 25 |
+
max_changed_lines_ratio: float = 0.35
|
| 26 |
+
auto_approve_validated: bool = False
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class RemediationOrchestrator:
|
| 30 |
+
"""Multi-agent remediation loop with deterministic validation gates."""
|
| 31 |
+
|
| 32 |
+
def __init__(
|
| 33 |
+
self,
|
| 34 |
+
config: RuntimeConfig | None = None,
|
| 35 |
+
settings: OrchestratorSettings | None = None,
|
| 36 |
+
):
|
| 37 |
+
self.config = config or RuntimeConfig.from_env()
|
| 38 |
+
self.settings = settings or OrchestratorSettings()
|
| 39 |
+
self.config.work_dir.mkdir(parents=True, exist_ok=True)
|
| 40 |
+
self.tmp_dir = self.config.work_dir / "tmp"
|
| 41 |
+
self.log_dir = self.config.work_dir / "logs"
|
| 42 |
+
self.tmp_dir.mkdir(parents=True, exist_ok=True)
|
| 43 |
+
self.log_dir.mkdir(parents=True, exist_ok=True)
|
| 44 |
+
|
| 45 |
+
self.pydantic_agents = create_pydantic_agent_bundle(self.config)
|
| 46 |
+
self.ingest_agent = IngestAgent()
|
| 47 |
+
self.policy_context_agent = PolicyContextAgent()
|
| 48 |
+
self.patch_agent = PatchAgent(self.config, self.log_dir)
|
| 49 |
+
self.validation_agent = ValidationAgent()
|
| 50 |
+
self.compliance_agent = ComplianceAgent()
|
| 51 |
+
self.human_approval_gate = HumanApprovalGate(self.settings.auto_approve_validated)
|
| 52 |
+
self.safety_agent = DeterministicSafetyAgent()
|
| 53 |
+
|
| 54 |
+
def remediate(self, request: IngestRequest) -> ApprovalDecision:
|
| 55 |
+
pre_safety = self.safety_agent.check_request(request)
|
| 56 |
+
if not pre_safety.allowed:
|
| 57 |
+
return ApprovalDecision(action="refused", reason=pre_safety.reason, n_retries=0)
|
| 58 |
+
|
| 59 |
+
bundle = self.ingest_agent.run(request, tmp_dir=self.tmp_dir)
|
| 60 |
+
enriched = self.policy_context_agent.run(bundle)
|
| 61 |
+
|
| 62 |
+
if not enriched.findings:
|
| 63 |
+
return ApprovalDecision(
|
| 64 |
+
action="needs_human_review",
|
| 65 |
+
reason="No deterministic scanner findings were found.",
|
| 66 |
+
n_retries=0,
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
feedback: str | None = None
|
| 70 |
+
previous_reason: str | None = None
|
| 71 |
+
best_patch: FixCandidate | None = None
|
| 72 |
+
best_result: ValidationResult | None = None
|
| 73 |
+
targeted = [finding.rule_id for finding in enriched.findings]
|
| 74 |
+
|
| 75 |
+
for attempt in range(self.settings.max_retries + 1):
|
| 76 |
+
candidate, schema_valid, _raw_text = self.patch_agent.run(
|
| 77 |
+
enriched,
|
| 78 |
+
retry_feedback=feedback,
|
| 79 |
+
max_context_chars=self.settings.max_context_chars,
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
if candidate is None or not schema_valid:
|
| 83 |
+
feedback = "Output did not match the FixCandidate JSON schema."
|
| 84 |
+
previous_reason = feedback
|
| 85 |
+
continue
|
| 86 |
+
|
| 87 |
+
best_patch = candidate
|
| 88 |
+
post_safety = self.safety_agent.check_patch(
|
| 89 |
+
request.file_content,
|
| 90 |
+
candidate.fixed_file,
|
| 91 |
+
candidate.patch,
|
| 92 |
+
)
|
| 93 |
+
if not post_safety.allowed:
|
| 94 |
+
return ApprovalDecision(
|
| 95 |
+
action="refused",
|
| 96 |
+
reason=post_safety.reason,
|
| 97 |
+
patch=candidate,
|
| 98 |
+
n_retries=attempt,
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
result = self.validation_agent.run(
|
| 102 |
+
request,
|
| 103 |
+
candidate,
|
| 104 |
+
targeted,
|
| 105 |
+
tmp_dir=self.tmp_dir,
|
| 106 |
+
max_changed_lines_ratio=self.settings.max_changed_lines_ratio,
|
| 107 |
+
)
|
| 108 |
+
result.pre_summary = bundle.pre_summary
|
| 109 |
+
best_result = result
|
| 110 |
+
|
| 111 |
+
if result.verdict == "accept":
|
| 112 |
+
compliance = self.compliance_agent.run(bundle.pre_summary, result)
|
| 113 |
+
return self.human_approval_gate.run(candidate, result, compliance, attempt)
|
| 114 |
+
|
| 115 |
+
if result.verdict == "reject" or result.retry_reason == previous_reason:
|
| 116 |
+
return ApprovalDecision(
|
| 117 |
+
action="rejected",
|
| 118 |
+
reason=result.retry_reason or "Validation rejected patch.",
|
| 119 |
+
patch=candidate,
|
| 120 |
+
validation=result,
|
| 121 |
+
n_retries=attempt,
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
previous_reason = result.retry_reason
|
| 125 |
+
feedback = compress_feedback(result, attempt=attempt, max_retries=self.settings.max_retries)
|
| 126 |
+
|
| 127 |
+
return ApprovalDecision(
|
| 128 |
+
action="needs_human_review",
|
| 129 |
+
reason=f"Max retries reached: {feedback}",
|
| 130 |
+
patch=best_patch,
|
| 131 |
+
validation=best_result,
|
| 132 |
+
n_retries=self.settings.max_retries,
|
| 133 |
+
)
|
iac_secfix_agents/policy.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from .schemas import EnrichedFinding, EnrichedFindings, ScanBundle
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
POLICY_DESCRIPTIONS: dict[str, str] = {
|
| 7 |
+
"CKV_AWS_18": "S3 buckets should have access logging enabled.",
|
| 8 |
+
"CKV_AWS_20": "S3 buckets should not allow public ACLs.",
|
| 9 |
+
"CKV_AWS_21": "S3 buckets should have versioning enabled.",
|
| 10 |
+
"CKV_AWS_23": "Security group ingress rules should not allow unrestricted access.",
|
| 11 |
+
"CKV_AWS_24": "Security group ingress rules should not allow unrestricted SSH.",
|
| 12 |
+
"CKV_AWS_79": "EC2 instances should require IMDSv2.",
|
| 13 |
+
"CKV_AWS_144": "S3 buckets should have cross-region replication enabled when required.",
|
| 14 |
+
"CKV_AWS_145": "S3 buckets should use KMS encryption.",
|
| 15 |
+
"CKV2_AWS_6": "S3 public access block should block public ACLs and policies.",
|
| 16 |
+
"CKV2_AWS_61": "S3 buckets should have lifecycle configuration.",
|
| 17 |
+
"CKV2_AWS_62": "S3 buckets should have event notifications configured when required.",
|
| 18 |
+
"CKV2_AWS_64": "KMS keys should have key rotation enabled.",
|
| 19 |
+
"CKV_K8S_8": "Kubernetes containers should not run as root.",
|
| 20 |
+
"CKV_K8S_9": "Kubernetes containers should not run with privileged escalation.",
|
| 21 |
+
"CKV_K8S_10": "Kubernetes containers should set CPU requests.",
|
| 22 |
+
"CKV_K8S_11": "Kubernetes containers should set CPU limits.",
|
| 23 |
+
"CKV_K8S_12": "Kubernetes containers should set memory requests.",
|
| 24 |
+
"CKV_K8S_13": "Kubernetes containers should set memory limits.",
|
| 25 |
+
"CKV_K8S_14": "Kubernetes images should use immutable tags.",
|
| 26 |
+
"CKV_K8S_20": "Kubernetes containers should not run privileged.",
|
| 27 |
+
"CKV_K8S_21": "Kubernetes default namespace should not be used.",
|
| 28 |
+
"CKV_K8S_22": "Kubernetes containers should use read-only root filesystems where possible.",
|
| 29 |
+
"CKV_K8S_23": "Kubernetes containers should drop dangerous capabilities.",
|
| 30 |
+
"CKV_K8S_28": "Kubernetes containers should minimize Linux capabilities.",
|
| 31 |
+
"CKV_K8S_29": "Kubernetes containers should not allow privilege escalation.",
|
| 32 |
+
"CKV_K8S_31": "Kubernetes seccomp profile should be configured.",
|
| 33 |
+
"CKV_K8S_37": "Kubernetes containers should minimize capabilities.",
|
| 34 |
+
"CKV_K8S_38": "Kubernetes containers should run as non-root.",
|
| 35 |
+
"CKV_K8S_40": "Kubernetes containers should not run as UID 0.",
|
| 36 |
+
"CKV_K8S_43": "Kubernetes images should use digest or immutable tags.",
|
| 37 |
+
"CKV_TF_1": "Terraform should pin module sources to immutable versions.",
|
| 38 |
+
"CKV_TF_2": "Terraform providers should be version constrained.",
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
REMEDIATION_HINTS: dict[str, str] = {
|
| 43 |
+
"CKV_AWS_20": "For aws_s3_bucket_acl, avoid public-read/public-read-write. Prefer private ACLs and ownership controls.",
|
| 44 |
+
"CKV_AWS_21": "Add aws_s3_bucket_versioning with versioning_configuration { status = \"Enabled\" } for the target bucket.",
|
| 45 |
+
"CKV_AWS_23": "Restrict ingress CIDR blocks from 0.0.0.0/0 to a known trusted CIDR. If unknown, require human approval.",
|
| 46 |
+
"CKV_AWS_24": "Do not expose port 22 to 0.0.0.0/0. Narrow CIDR or remove the SSH ingress rule if not required.",
|
| 47 |
+
"CKV_AWS_79": "Add metadata_options { http_tokens = \"required\" } to aws_instance resources.",
|
| 48 |
+
"CKV_AWS_145": "Configure aws_s3_bucket_server_side_encryption_configuration with aws:kms only when a real KMS key exists; otherwise require approval.",
|
| 49 |
+
"CKV2_AWS_6": "Add aws_s3_bucket_public_access_block with all four block/restrict flags set to true.",
|
| 50 |
+
"CKV2_AWS_61": "Add aws_s3_bucket_lifecycle_configuration only when a retention policy is known; otherwise require approval.",
|
| 51 |
+
"CKV2_AWS_64": "Set enable_key_rotation = true on aws_kms_key resources.",
|
| 52 |
+
"CKV_K8S_8": "Add securityContext.runAsNonRoot: true and avoid UID 0.",
|
| 53 |
+
"CKV_K8S_9": "Set securityContext.allowPrivilegeEscalation: false on every container.",
|
| 54 |
+
"CKV_K8S_10": "Add resources.requests.cpu to each container.",
|
| 55 |
+
"CKV_K8S_11": "Add resources.limits.cpu to each container.",
|
| 56 |
+
"CKV_K8S_12": "Add resources.requests.memory to each container.",
|
| 57 |
+
"CKV_K8S_13": "Add resources.limits.memory to each container.",
|
| 58 |
+
"CKV_K8S_14": "Replace latest or mutable image tags with immutable versions if a real version is known.",
|
| 59 |
+
"CKV_K8S_20": "Set privileged: false in each container securityContext.",
|
| 60 |
+
"CKV_K8S_22": "Set readOnlyRootFilesystem: true only when the workload does not require writes, otherwise require approval.",
|
| 61 |
+
"CKV_K8S_29": "Set allowPrivilegeEscalation: false on each container securityContext.",
|
| 62 |
+
"CKV_K8S_31": "Set seccompProfile.type: RuntimeDefault at pod or container securityContext.",
|
| 63 |
+
"CKV_K8S_38": "Set runAsNonRoot: true at pod or container securityContext.",
|
| 64 |
+
"CKV_K8S_40": "Set runAsUser to a non-zero UID only when compatible with the image; otherwise require approval.",
|
| 65 |
+
"CKV_TF_1": "Pin module source references to tags or commits. Do not invent a version if none is known.",
|
| 66 |
+
"CKV_TF_2": "Add required_providers version constraints compatible with the configuration.",
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def enrich_findings(bundle: ScanBundle) -> EnrichedFindings:
|
| 71 |
+
findings = []
|
| 72 |
+
for finding in bundle.findings:
|
| 73 |
+
findings.append(
|
| 74 |
+
EnrichedFinding(
|
| 75 |
+
**finding.model_dump(),
|
| 76 |
+
policy_description=POLICY_DESCRIPTIONS.get(finding.rule_id, finding.message),
|
| 77 |
+
remediation_hint=REMEDIATION_HINTS.get(finding.rule_id),
|
| 78 |
+
cis_controls=[],
|
| 79 |
+
doc_snippets=[],
|
| 80 |
+
)
|
| 81 |
+
)
|
| 82 |
+
return EnrichedFindings(file=bundle.file, findings=findings)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def build_rule_hints_md(rule_ids: list[str]) -> str:
|
| 86 |
+
lines: list[str] = []
|
| 87 |
+
for rule_id in sorted(set(rule_ids)):
|
| 88 |
+
description = POLICY_DESCRIPTIONS.get(rule_id, "Scanner policy finding.")
|
| 89 |
+
hint = REMEDIATION_HINTS.get(rule_id, "Use the scanner message and make the smallest safe in-file remediation.")
|
| 90 |
+
lines.append(f"- {rule_id}: {description} Fix: {hint}")
|
| 91 |
+
return "\n".join(lines)
|
iac_secfix_agents/safety.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
from difflib import unified_diff
|
| 5 |
+
|
| 6 |
+
from .schemas import IngestRequest, SafetyVerdict
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
REQUEST_DENY_PATTERNS: dict[str, re.Pattern[str]] = {
|
| 10 |
+
"disable_security": re.compile(
|
| 11 |
+
r"\b(disable|remove|turn off)\b.*\b(encryption|logging|rbac|audit|tls|network policy|securitycontext)\b",
|
| 12 |
+
re.IGNORECASE,
|
| 13 |
+
),
|
| 14 |
+
"make_public": re.compile(r"\b(make|set|allow)\b.*\b(public|0\.0\.0\.0/0|anonymous)\b", re.IGNORECASE),
|
| 15 |
+
"scanner_suppression": re.compile(r"\b(checkov:skip|nosec|tfsec:ignore|suppress)\b", re.IGNORECASE),
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
PATCH_DENY_PATTERNS: dict[str, re.Pattern[str]] = {
|
| 20 |
+
"scanner_suppression": re.compile(r"(checkov:skip|nosec|tfsec:ignore)", re.IGNORECASE),
|
| 21 |
+
"public_acl": re.compile(r'acl\s*=\s*"(public-read|public-read-write)"', re.IGNORECASE),
|
| 22 |
+
"open_ingress": re.compile(r"0\.0\.0\.0/0"),
|
| 23 |
+
"disable_encryption": re.compile(
|
| 24 |
+
r"(encrypt(ed|ion)?\s*=\s*false|storage_encrypted\s*=\s*false|kms_key_id\s*=\s*null)",
|
| 25 |
+
re.IGNORECASE,
|
| 26 |
+
),
|
| 27 |
+
"privileged_container": re.compile(r"(privileged:\s*true|allowPrivilegeEscalation:\s*true|runAsUser:\s*0)", re.IGNORECASE),
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class DeterministicSafetyAgent:
|
| 32 |
+
"""Small deterministic safety gate around the LLM patcher."""
|
| 33 |
+
|
| 34 |
+
def check_request(self, request: IngestRequest) -> SafetyVerdict:
|
| 35 |
+
text = "\n".join([request.user_intent or "", request.file_content])
|
| 36 |
+
matches = [name for name, pattern in REQUEST_DENY_PATTERNS.items() if pattern.search(text)]
|
| 37 |
+
if matches:
|
| 38 |
+
return SafetyVerdict(
|
| 39 |
+
allowed=False,
|
| 40 |
+
reason="Request appears to weaken security posture.",
|
| 41 |
+
matched_rules=matches,
|
| 42 |
+
)
|
| 43 |
+
return SafetyVerdict(allowed=True)
|
| 44 |
+
|
| 45 |
+
def check_patch(self, original_file: str, fixed_file: str, patch_text: str = "") -> SafetyVerdict:
|
| 46 |
+
diff_text = patch_text or "\n".join(
|
| 47 |
+
unified_diff(original_file.splitlines(), fixed_file.splitlines(), lineterm="")
|
| 48 |
+
)
|
| 49 |
+
added_lines = "\n".join(
|
| 50 |
+
line[1:] for line in diff_text.splitlines() if line.startswith("+") and not line.startswith("+++")
|
| 51 |
+
)
|
| 52 |
+
text = "\n".join([added_lines, patch_text])
|
| 53 |
+
matches = [name for name, pattern in PATCH_DENY_PATTERNS.items() if pattern.search(text)]
|
| 54 |
+
if matches:
|
| 55 |
+
return SafetyVerdict(
|
| 56 |
+
allowed=False,
|
| 57 |
+
reason="Patch appears to introduce or preserve unsafe configuration.",
|
| 58 |
+
matched_rules=matches,
|
| 59 |
+
)
|
| 60 |
+
return SafetyVerdict(allowed=True)
|
iac_secfix_agents/scanners.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import subprocess
|
| 5 |
+
import tempfile
|
| 6 |
+
from difflib import unified_diff
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from .schemas import (
|
| 11 |
+
CheckovReport,
|
| 12 |
+
FileEnvelope,
|
| 13 |
+
Finding,
|
| 14 |
+
FixCandidate,
|
| 15 |
+
IaCType,
|
| 16 |
+
IngestRequest,
|
| 17 |
+
ScanBundle,
|
| 18 |
+
ValidationResult,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class ScannerUnavailable(RuntimeError):
|
| 23 |
+
"""Raised when a required deterministic scanner is unavailable."""
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def detect_iac_type(file_name: str, content: str = "") -> IaCType:
|
| 27 |
+
lower = file_name.lower()
|
| 28 |
+
if lower.endswith(".tf"):
|
| 29 |
+
return "terraform"
|
| 30 |
+
if lower.endswith((".yaml", ".yml")):
|
| 31 |
+
return "kubernetes"
|
| 32 |
+
if lower == "dockerfile":
|
| 33 |
+
return "dockerfile"
|
| 34 |
+
if "apiVersion:" in content and "kind:" in content:
|
| 35 |
+
return "kubernetes"
|
| 36 |
+
return "terraform"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _summary_from_reports(reports: list[CheckovReport]) -> dict[str, Any]:
|
| 40 |
+
failed = 0
|
| 41 |
+
passed = 0
|
| 42 |
+
skipped = 0
|
| 43 |
+
for report in reports:
|
| 44 |
+
summary = report.summary or {}
|
| 45 |
+
failed += int(summary.get("failed", 0) or 0)
|
| 46 |
+
passed += int(summary.get("passed", 0) or 0)
|
| 47 |
+
skipped += int(summary.get("skipped", 0) or 0)
|
| 48 |
+
return {"failed": failed, "passed": passed, "skipped": skipped}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def run_checkov(path: Path, timeout_s: int = 120) -> list[CheckovReport]:
|
| 52 |
+
"""Run Checkov against a file or directory and normalize JSON output."""
|
| 53 |
+
|
| 54 |
+
cmd = [
|
| 55 |
+
"checkov",
|
| 56 |
+
"-f" if path.is_file() else "-d",
|
| 57 |
+
str(path),
|
| 58 |
+
"-o",
|
| 59 |
+
"json",
|
| 60 |
+
"--quiet",
|
| 61 |
+
"--compact",
|
| 62 |
+
]
|
| 63 |
+
try:
|
| 64 |
+
proc = subprocess.run(
|
| 65 |
+
cmd,
|
| 66 |
+
text=True,
|
| 67 |
+
capture_output=True,
|
| 68 |
+
timeout=timeout_s,
|
| 69 |
+
check=False,
|
| 70 |
+
)
|
| 71 |
+
except FileNotFoundError as exc:
|
| 72 |
+
raise ScannerUnavailable("checkov is not installed or not on PATH") from exc
|
| 73 |
+
|
| 74 |
+
output = proc.stdout.strip()
|
| 75 |
+
if not output:
|
| 76 |
+
output = proc.stderr.strip()
|
| 77 |
+
if not output:
|
| 78 |
+
return []
|
| 79 |
+
|
| 80 |
+
try:
|
| 81 |
+
raw = json.loads(output)
|
| 82 |
+
except json.JSONDecodeError as exc:
|
| 83 |
+
raise RuntimeError(f"Checkov did not return JSON: {output[:500]}") from exc
|
| 84 |
+
|
| 85 |
+
if isinstance(raw, list):
|
| 86 |
+
return [CheckovReport.model_validate(item) for item in raw if isinstance(item, dict)]
|
| 87 |
+
if isinstance(raw, dict) and "results" in raw:
|
| 88 |
+
return [CheckovReport.model_validate(raw)]
|
| 89 |
+
if isinstance(raw, dict) and "check_type" in raw:
|
| 90 |
+
return [CheckovReport.model_validate(raw)]
|
| 91 |
+
return []
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def checkov_to_findings(reports: list[CheckovReport]) -> list[Finding]:
|
| 95 |
+
findings: list[Finding] = []
|
| 96 |
+
seen: set[tuple[str, str, int | None, int | None]] = set()
|
| 97 |
+
|
| 98 |
+
for report in reports:
|
| 99 |
+
for raw in report.failed_checks:
|
| 100 |
+
rule_id = str(raw.get("check_id") or "")
|
| 101 |
+
if not rule_id:
|
| 102 |
+
continue
|
| 103 |
+
line_range = raw.get("file_line_range") or []
|
| 104 |
+
line_start = line_range[0] if len(line_range) >= 1 else None
|
| 105 |
+
line_end = line_range[1] if len(line_range) >= 2 else line_start
|
| 106 |
+
resource = str(raw.get("resource") or "")
|
| 107 |
+
key = (rule_id, resource, line_start, line_end)
|
| 108 |
+
if key in seen:
|
| 109 |
+
continue
|
| 110 |
+
seen.add(key)
|
| 111 |
+
severity = str(raw.get("severity") or "UNKNOWN").upper()
|
| 112 |
+
if severity not in {"CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"}:
|
| 113 |
+
severity = "UNKNOWN"
|
| 114 |
+
findings.append(
|
| 115 |
+
Finding(
|
| 116 |
+
rule_id=rule_id,
|
| 117 |
+
severity=severity, # type: ignore[arg-type]
|
| 118 |
+
resource=resource,
|
| 119 |
+
message=str(raw.get("check_name") or raw.get("message") or ""),
|
| 120 |
+
file_path=str(raw.get("file_path") or ""),
|
| 121 |
+
line_start=line_start,
|
| 122 |
+
line_end=line_end,
|
| 123 |
+
guideline=raw.get("guideline"),
|
| 124 |
+
)
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
return findings
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def yaml_parse_check(content: str) -> tuple[bool, str | None]:
|
| 131 |
+
try:
|
| 132 |
+
import yaml
|
| 133 |
+
|
| 134 |
+
list(yaml.safe_load_all(content))
|
| 135 |
+
return True, None
|
| 136 |
+
except Exception as exc: # pragma: no cover - environment dependent
|
| 137 |
+
return False, str(exc)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def hcl_parse_check(content: str) -> tuple[bool, str | None]:
|
| 141 |
+
try:
|
| 142 |
+
import hcl2
|
| 143 |
+
|
| 144 |
+
hcl2.loads(content)
|
| 145 |
+
return True, None
|
| 146 |
+
except Exception as exc: # pragma: no cover - environment dependent
|
| 147 |
+
return False, str(exc)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def parse_check(content: str, iac_type: IaCType) -> tuple[bool, str | None]:
|
| 151 |
+
if iac_type == "kubernetes":
|
| 152 |
+
return yaml_parse_check(content)
|
| 153 |
+
if iac_type == "terraform":
|
| 154 |
+
return hcl_parse_check(content)
|
| 155 |
+
return True, None
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def diff_stats(original: str, fixed: str) -> tuple[int, int, int, float]:
|
| 159 |
+
original_lines = original.splitlines()
|
| 160 |
+
fixed_lines = fixed.splitlines()
|
| 161 |
+
added = 0
|
| 162 |
+
removed = 0
|
| 163 |
+
for line in unified_diff(original_lines, fixed_lines, lineterm=""):
|
| 164 |
+
if line.startswith(("+++", "---", "@@")):
|
| 165 |
+
continue
|
| 166 |
+
if line.startswith("+"):
|
| 167 |
+
added += 1
|
| 168 |
+
elif line.startswith("-"):
|
| 169 |
+
removed += 1
|
| 170 |
+
denominator = max(len(original_lines), 1)
|
| 171 |
+
return added, removed, added + removed, (added + removed) / denominator
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def build_unified_patch(original: str, fixed: str, fromfile: str = "original", tofile: str = "fixed") -> str:
|
| 175 |
+
return "\n".join(
|
| 176 |
+
unified_diff(
|
| 177 |
+
original.splitlines(),
|
| 178 |
+
fixed.splitlines(),
|
| 179 |
+
fromfile=fromfile,
|
| 180 |
+
tofile=tofile,
|
| 181 |
+
lineterm="",
|
| 182 |
+
)
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def _write_temp_file(request: IngestRequest, directory: Path) -> Path:
|
| 187 |
+
iac_type = request.iac_type or detect_iac_type(request.file_name, request.file_content)
|
| 188 |
+
suffix = ".tf" if iac_type == "terraform" else ".yaml"
|
| 189 |
+
path = directory / f"input{suffix}"
|
| 190 |
+
path.write_text(request.file_content)
|
| 191 |
+
return path
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def unified_scan(request: IngestRequest, tmp_dir: Path | None = None) -> ScanBundle:
|
| 195 |
+
iac_type = request.iac_type or detect_iac_type(request.file_name, request.file_content)
|
| 196 |
+
envelope = FileEnvelope(
|
| 197 |
+
file_name=request.file_name,
|
| 198 |
+
file_content=request.file_content,
|
| 199 |
+
iac_type=iac_type,
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
if tmp_dir is None:
|
| 203 |
+
with tempfile.TemporaryDirectory(prefix="iac_secfix_scan_") as dirname:
|
| 204 |
+
path = _write_temp_file(request, Path(dirname))
|
| 205 |
+
reports = run_checkov(path)
|
| 206 |
+
else:
|
| 207 |
+
tmp_dir.mkdir(parents=True, exist_ok=True)
|
| 208 |
+
path = _write_temp_file(request, tmp_dir)
|
| 209 |
+
reports = run_checkov(path)
|
| 210 |
+
|
| 211 |
+
return ScanBundle(
|
| 212 |
+
file=envelope,
|
| 213 |
+
findings=checkov_to_findings(reports),
|
| 214 |
+
pre_summary=_summary_from_reports(reports),
|
| 215 |
+
scanner="checkov",
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def validate_candidate(
|
| 220 |
+
original_req: IngestRequest,
|
| 221 |
+
candidate: FixCandidate,
|
| 222 |
+
targeted: list[str],
|
| 223 |
+
tmp_dir: Path | None = None,
|
| 224 |
+
max_changed_lines_ratio: float = 0.35,
|
| 225 |
+
) -> ValidationResult:
|
| 226 |
+
iac_type = original_req.iac_type or detect_iac_type(original_req.file_name, original_req.file_content)
|
| 227 |
+
parse_ok, parse_error = parse_check(candidate.fixed_file, iac_type)
|
| 228 |
+
_, _, _, ratio = diff_stats(original_req.file_content, candidate.fixed_file)
|
| 229 |
+
|
| 230 |
+
post_request = IngestRequest(
|
| 231 |
+
file_name=original_req.file_name,
|
| 232 |
+
file_content=candidate.fixed_file,
|
| 233 |
+
iac_type=iac_type,
|
| 234 |
+
user_intent=original_req.user_intent,
|
| 235 |
+
)
|
| 236 |
+
bundle = unified_scan(post_request, tmp_dir=tmp_dir)
|
| 237 |
+
post_ids = {finding.rule_id for finding in bundle.findings}
|
| 238 |
+
targeted_set = set(targeted)
|
| 239 |
+
unresolved = sorted(targeted_set & post_ids)
|
| 240 |
+
resolved = sorted(targeted_set - post_ids)
|
| 241 |
+
new_findings = [finding for finding in bundle.findings if finding.rule_id not in targeted_set]
|
| 242 |
+
new_ch = sum(finding.severity in {"CRITICAL", "HIGH"} for finding in new_findings)
|
| 243 |
+
|
| 244 |
+
if not parse_ok:
|
| 245 |
+
verdict = "retry"
|
| 246 |
+
reason = f"parse failure: {parse_error}"
|
| 247 |
+
elif new_ch >= 2:
|
| 248 |
+
verdict = "reject"
|
| 249 |
+
reason = "new_critical_high_count >= 2"
|
| 250 |
+
elif not unresolved and new_ch == 0 and ratio <= max_changed_lines_ratio:
|
| 251 |
+
verdict = "accept"
|
| 252 |
+
reason = None
|
| 253 |
+
else:
|
| 254 |
+
verdict = "retry"
|
| 255 |
+
reason = "unresolved targeted findings, new findings, or over-broad diff"
|
| 256 |
+
|
| 257 |
+
return ValidationResult(
|
| 258 |
+
parse_ok=parse_ok,
|
| 259 |
+
targeted_resolved=resolved,
|
| 260 |
+
targeted_unresolved=unresolved,
|
| 261 |
+
new_findings=new_findings,
|
| 262 |
+
new_critical_high_count=new_ch,
|
| 263 |
+
changed_lines_ratio=ratio,
|
| 264 |
+
post_summary=bundle.pre_summary,
|
| 265 |
+
verdict=verdict, # type: ignore[arg-type]
|
| 266 |
+
retry_reason=reason,
|
| 267 |
+
)
|
iac_secfix_agents/schemas.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any, Literal
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
IaCType = Literal["terraform", "kubernetes", "dockerfile"]
|
| 9 |
+
Severity = Literal["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO", "UNKNOWN"]
|
| 10 |
+
ValidationVerdict = Literal["accept", "retry", "reject"]
|
| 11 |
+
ApprovalAction = Literal["approved", "refused", "rejected", "needs_human_review"]
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class CheckovCheck(BaseModel):
|
| 15 |
+
check_id: str = ""
|
| 16 |
+
bc_check_id: str | None = None
|
| 17 |
+
check_name: str = ""
|
| 18 |
+
check_result: dict[str, Any] = Field(default_factory=dict)
|
| 19 |
+
code_block: list[Any] = Field(default_factory=list)
|
| 20 |
+
file_path: str = ""
|
| 21 |
+
file_line_range: list[int] = Field(default_factory=list)
|
| 22 |
+
resource: str = ""
|
| 23 |
+
evaluations: dict[str, Any] | None = None
|
| 24 |
+
guideline: str | None = None
|
| 25 |
+
severity: str | None = None
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class CheckovReport(BaseModel):
|
| 29 |
+
check_type: str = ""
|
| 30 |
+
results: dict[str, Any] = Field(default_factory=dict)
|
| 31 |
+
summary: dict[str, Any] = Field(default_factory=dict)
|
| 32 |
+
|
| 33 |
+
@property
|
| 34 |
+
def failed_checks(self) -> list[dict[str, Any]]:
|
| 35 |
+
checks = self.results.get("failed_checks", [])
|
| 36 |
+
return checks if isinstance(checks, list) else []
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class Finding(BaseModel):
|
| 40 |
+
rule_id: str
|
| 41 |
+
severity: Severity = "UNKNOWN"
|
| 42 |
+
resource: str = ""
|
| 43 |
+
message: str = ""
|
| 44 |
+
file_path: str = ""
|
| 45 |
+
line_start: int | None = None
|
| 46 |
+
line_end: int | None = None
|
| 47 |
+
guideline: str | None = None
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class FileEnvelope(BaseModel):
|
| 51 |
+
file_name: str
|
| 52 |
+
file_content: str
|
| 53 |
+
iac_type: IaCType
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class IngestRequest(BaseModel):
|
| 57 |
+
file_name: str
|
| 58 |
+
file_content: str
|
| 59 |
+
iac_type: IaCType | None = None
|
| 60 |
+
user_intent: str | None = None
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class ScanBundle(BaseModel):
|
| 64 |
+
file: FileEnvelope
|
| 65 |
+
findings: list[Finding] = Field(default_factory=list)
|
| 66 |
+
pre_summary: dict[str, Any] = Field(default_factory=dict)
|
| 67 |
+
scanner: str = "checkov"
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class EnrichedFinding(Finding):
|
| 71 |
+
policy_description: str = ""
|
| 72 |
+
cis_controls: list[str] = Field(default_factory=list)
|
| 73 |
+
doc_snippets: list[str] = Field(default_factory=list)
|
| 74 |
+
remediation_hint: str | None = None
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class EnrichedFindings(BaseModel):
|
| 78 |
+
file: FileEnvelope
|
| 79 |
+
findings: list[EnrichedFinding] = Field(default_factory=list)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class FixCandidate(BaseModel):
|
| 83 |
+
patch: str = ""
|
| 84 |
+
fixed_file: str
|
| 85 |
+
resolved_policy_ids: list[str] = Field(default_factory=list)
|
| 86 |
+
explanation: str = ""
|
| 87 |
+
verification_commands: list[str] = Field(default_factory=list)
|
| 88 |
+
risk_notes: list[str] = Field(default_factory=list)
|
| 89 |
+
requires_human_approval: bool = True
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class ValidationResult(BaseModel):
|
| 93 |
+
parse_ok: bool
|
| 94 |
+
targeted_resolved: list[str] = Field(default_factory=list)
|
| 95 |
+
targeted_unresolved: list[str] = Field(default_factory=list)
|
| 96 |
+
new_findings: list[Finding] = Field(default_factory=list)
|
| 97 |
+
new_critical_high_count: int = 0
|
| 98 |
+
changed_lines_ratio: float = 1.0
|
| 99 |
+
pre_summary: dict[str, Any] = Field(default_factory=dict)
|
| 100 |
+
post_summary: dict[str, Any] = Field(default_factory=dict)
|
| 101 |
+
verdict: ValidationVerdict
|
| 102 |
+
retry_reason: str | None = None
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
class SafetyVerdict(BaseModel):
|
| 106 |
+
allowed: bool
|
| 107 |
+
reason: str = ""
|
| 108 |
+
matched_rules: list[str] = Field(default_factory=list)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
class ComplianceReport(BaseModel):
|
| 112 |
+
cis_pass_rate_before: float = 0.0
|
| 113 |
+
cis_pass_rate_after: float = 0.0
|
| 114 |
+
network_exposure_reduction: float = 0.0
|
| 115 |
+
iam_privilege_reduction: float = 0.0
|
| 116 |
+
targeted_resolution_rate: float = 0.0
|
| 117 |
+
scanner_clean: bool = False
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
class ApprovalDecision(BaseModel):
|
| 121 |
+
action: ApprovalAction
|
| 122 |
+
reason: str = ""
|
| 123 |
+
patch: FixCandidate | None = None
|
| 124 |
+
validation: ValidationResult | None = None
|
| 125 |
+
compliance: ComplianceReport | None = None
|
| 126 |
+
n_retries: int = 0
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
class EvalRow(BaseModel):
|
| 130 |
+
id: str
|
| 131 |
+
pre_findings: list[str] = Field(default_factory=list)
|
| 132 |
+
post_findings: list[str] = Field(default_factory=list)
|
| 133 |
+
targeted: list[str] = Field(default_factory=list)
|
| 134 |
+
parse_ok: bool = False
|
| 135 |
+
new_critical_high: int = 0
|
| 136 |
+
changed_lines_ratio: float = 1.0
|
| 137 |
+
n_retries: int = 0
|
| 138 |
+
latency_s: float = 0.0
|
| 139 |
+
schema_valid: bool = False
|
| 140 |
+
unsafe_action: bool = False
|
iac_secfix_space/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .ui import build_demo
|
| 2 |
+
|
| 3 |
+
__all__ = ["build_demo"]
|
iac_secfix_space/demo_data.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
DEMO_DIR = Path(__file__).parent.parent / "demo_examples"
|
| 7 |
+
|
| 8 |
+
DEMO_LABELS: dict[str, str] = {
|
| 9 |
+
"demo_sg_open.json": "Security Group: Open SSH Ingress (Terraform)",
|
| 10 |
+
"demo_s3_public.json": "S3 Bucket: Public Access Enabled (Terraform)",
|
| 11 |
+
"demo_iam_wild.json": "IAM: Wildcard Permissions (Terraform)",
|
| 12 |
+
"demo_rds_unencr.json": "RDS: Unencrypted Storage (Terraform)",
|
| 13 |
+
"demo_k8s_root.json": "Kubernetes: Container Running as Root",
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def load_demos() -> dict[str, dict]:
|
| 18 |
+
result: dict[str, dict] = {}
|
| 19 |
+
for fname, label in DEMO_LABELS.items():
|
| 20 |
+
path = DEMO_DIR / fname
|
| 21 |
+
if path.exists():
|
| 22 |
+
result[label] = json.loads(path.read_text())
|
| 23 |
+
return result
|
iac_secfix_space/hf_inference.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build a RuntimeConfig that routes to the fine-tuned LoRA via HF inference."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 9 |
+
|
| 10 |
+
LORA_MODEL_ID = "selcuksntrk/iac-secfix-qwen35-9b-lora"
|
| 11 |
+
HF_SERVERLESS_BASE = "https://api-inference.huggingface.co/models"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def has_hf_config() -> bool:
|
| 15 |
+
return bool(
|
| 16 |
+
os.getenv("HF_TOKEN")
|
| 17 |
+
or os.getenv("IAC_SECFIX_HF_TOKEN")
|
| 18 |
+
or os.getenv("IAC_SECFIX_HF_ENDPOINT")
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def build_hf_runtime_config():
|
| 23 |
+
"""Return RuntimeConfig for HF inference.
|
| 24 |
+
|
| 25 |
+
Priority:
|
| 26 |
+
1. IAC_SECFIX_HF_ENDPOINT β a dedicated HF Inference Endpoint (most reliable,
|
| 27 |
+
works even without merging the LoRA into the base model).
|
| 28 |
+
2. HF serverless via the OpenAI-compatible path β works if the model has been
|
| 29 |
+
set up for serverless inference on HF Hub.
|
| 30 |
+
"""
|
| 31 |
+
from iac_secfix_agents.config import RuntimeConfig
|
| 32 |
+
|
| 33 |
+
token = os.getenv("HF_TOKEN") or os.getenv("IAC_SECFIX_HF_TOKEN", "")
|
| 34 |
+
endpoint = os.getenv("IAC_SECFIX_HF_ENDPOINT", "").rstrip("/")
|
| 35 |
+
|
| 36 |
+
if endpoint:
|
| 37 |
+
return RuntimeConfig(
|
| 38 |
+
base_url=endpoint,
|
| 39 |
+
api_key=token,
|
| 40 |
+
model=os.getenv("IAC_SECFIX_HF_MODEL", LORA_MODEL_ID),
|
| 41 |
+
temperature=0.0,
|
| 42 |
+
max_tokens=2048,
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
# Fall back to HF serverless (OpenAI-compatible chat completions path)
|
| 46 |
+
return RuntimeConfig(
|
| 47 |
+
base_url=f"{HF_SERVERLESS_BASE}/{LORA_MODEL_ID}/v1",
|
| 48 |
+
api_key=token,
|
| 49 |
+
model=LORA_MODEL_ID,
|
| 50 |
+
temperature=0.0,
|
| 51 |
+
max_tokens=2048,
|
| 52 |
+
)
|
iac_secfix_space/rendering.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
ACTION_STYLES: dict[str, str] = {
|
| 5 |
+
"approved": "background:#22c55e;color:white",
|
| 6 |
+
"needs_human_review": "background:#f97316;color:white",
|
| 7 |
+
"rejected": "background:#ef4444;color:white",
|
| 8 |
+
"refused": "background:#dc2626;color:white",
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def action_badge(action: str) -> str:
|
| 13 |
+
style = ACTION_STYLES.get(action, "background:#6b7280;color:white")
|
| 14 |
+
label = action.upper().replace("_", " ")
|
| 15 |
+
return f"<span style='{style};padding:5px 14px;border-radius:5px;font-weight:bold;font-size:1.1em'>{label}</span>"
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def render_findings_md(rule_ids: list[str], summary: dict) -> str:
|
| 19 |
+
failed = summary.get("failed", len(rule_ids))
|
| 20 |
+
crit = summary.get("CRITICAL", 0)
|
| 21 |
+
high = summary.get("HIGH", 0)
|
| 22 |
+
med = summary.get("MEDIUM", 0)
|
| 23 |
+
|
| 24 |
+
lines = [f"**{failed} failed checks** (CRITICAL: {crit} | HIGH: {high} | MEDIUM: {med})"]
|
| 25 |
+
for rid in rule_ids:
|
| 26 |
+
lines.append(f"- `{rid}`")
|
| 27 |
+
return "\n".join(lines)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def render_result(data: dict) -> tuple[str, str, str, str, str, str]:
|
| 31 |
+
decision = data.get("decision") or {}
|
| 32 |
+
action = decision.get("action", "unknown")
|
| 33 |
+
reason = decision.get("reason", "")
|
| 34 |
+
|
| 35 |
+
original = data.get("original_file", "")
|
| 36 |
+
patch_obj = decision.get("patch") or {}
|
| 37 |
+
fixed = patch_obj.get("fixed_file") or data.get("fixed_file", "")
|
| 38 |
+
|
| 39 |
+
pre_rules: list[str] = data.get("pre_rule_ids") or []
|
| 40 |
+
post_rules: list[str] = data.get("post_rule_ids") or []
|
| 41 |
+
pre_summary: dict = data.get("pre_summary") or {}
|
| 42 |
+
post_summary: dict = data.get("post_summary") or {}
|
| 43 |
+
|
| 44 |
+
validation = decision.get("validation") or data.get("validation") or {}
|
| 45 |
+
resolved: list[str] = validation.get("targeted_resolved") or []
|
| 46 |
+
unresolved: list[str] = validation.get("targeted_unresolved") or []
|
| 47 |
+
parse_ok: bool = validation.get("parse_ok", False)
|
| 48 |
+
changed_ratio: float = validation.get("changed_lines_ratio", 0.0)
|
| 49 |
+
|
| 50 |
+
explanation = patch_obj.get("explanation") or reason
|
| 51 |
+
risk_notes: list[str] = patch_obj.get("risk_notes") or []
|
| 52 |
+
|
| 53 |
+
status_html = (
|
| 54 |
+
f"<div style='margin:12px 0'>"
|
| 55 |
+
f"{action_badge(action)}"
|
| 56 |
+
f"<p style='margin-top:8px;color:#374151'>{reason}</p>"
|
| 57 |
+
f"</div>"
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
details_parts = [
|
| 61 |
+
"### Validation",
|
| 62 |
+
f"- Parse OK: {'Yes' if parse_ok else 'No'}",
|
| 63 |
+
f"- Changed lines: {changed_ratio:.0%}",
|
| 64 |
+
f"- Resolved: {', '.join(f'`{r}`' for r in resolved) if resolved else 'none'}",
|
| 65 |
+
f"- Unresolved: {', '.join(f'`{r}`' for r in unresolved) if unresolved else 'none'}",
|
| 66 |
+
"",
|
| 67 |
+
"### Explanation",
|
| 68 |
+
explanation,
|
| 69 |
+
]
|
| 70 |
+
if risk_notes:
|
| 71 |
+
details_parts += ["", "### Risk Notes"] + [f"- {n}" for n in risk_notes]
|
| 72 |
+
|
| 73 |
+
return (
|
| 74 |
+
original,
|
| 75 |
+
fixed,
|
| 76 |
+
status_html,
|
| 77 |
+
render_findings_md(pre_rules, pre_summary),
|
| 78 |
+
render_findings_md(post_rules, post_summary),
|
| 79 |
+
"\n".join(details_parts),
|
| 80 |
+
)
|
iac_secfix_space/ui.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 8 |
+
|
| 9 |
+
import gradio as gr
|
| 10 |
+
|
| 11 |
+
from .demo_data import load_demos
|
| 12 |
+
from .hf_inference import LORA_MODEL_ID, build_hf_runtime_config, has_hf_config
|
| 13 |
+
from .rendering import render_result
|
| 14 |
+
|
| 15 |
+
_N = 6 # number of output components
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _empty() -> tuple:
|
| 19 |
+
return ("",) * _N
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _demo_outputs(label: str | None, demos: dict) -> tuple:
|
| 23 |
+
if not label or label not in demos:
|
| 24 |
+
return _empty()
|
| 25 |
+
return render_result(demos[label])
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _live_outputs(file_content: str, iac_type: str) -> tuple:
|
| 29 |
+
if not file_content.strip():
|
| 30 |
+
return _empty()
|
| 31 |
+
try:
|
| 32 |
+
from iac_secfix_agents import IngestRequest, RemediationOrchestrator
|
| 33 |
+
|
| 34 |
+
decision = RemediationOrchestrator().remediate(
|
| 35 |
+
IngestRequest(
|
| 36 |
+
file_name="live_input.tf" if iac_type == "terraform" else "live_input.yaml",
|
| 37 |
+
file_content=file_content,
|
| 38 |
+
iac_type=iac_type, # type: ignore[arg-type]
|
| 39 |
+
)
|
| 40 |
+
)
|
| 41 |
+
val = decision.validation
|
| 42 |
+
data = {
|
| 43 |
+
"original_file": file_content,
|
| 44 |
+
"fixed_file": decision.patch.fixed_file if decision.patch else "",
|
| 45 |
+
"decision": decision.model_dump(mode="json"),
|
| 46 |
+
"pre_rule_ids": [],
|
| 47 |
+
"post_rule_ids": val.targeted_unresolved if val else [],
|
| 48 |
+
"pre_summary": val.pre_summary if val else {},
|
| 49 |
+
"post_summary": val.post_summary if val else {},
|
| 50 |
+
}
|
| 51 |
+
return render_result(data)
|
| 52 |
+
except Exception as exc:
|
| 53 |
+
return (
|
| 54 |
+
file_content, "",
|
| 55 |
+
f"<div style='color:red;padding:8px'><b>Error:</b> {exc}</div>",
|
| 56 |
+
"", "", "",
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _lora_outputs(file_content: str, iac_type: str) -> tuple:
|
| 61 |
+
if not file_content.strip():
|
| 62 |
+
return _empty()
|
| 63 |
+
try:
|
| 64 |
+
from iac_secfix_agents import IngestRequest, OrchestratorSettings, RemediationOrchestrator
|
| 65 |
+
|
| 66 |
+
config = build_hf_runtime_config()
|
| 67 |
+
decision = RemediationOrchestrator(config=config).remediate(
|
| 68 |
+
IngestRequest(
|
| 69 |
+
file_name="lora_input.tf" if iac_type == "terraform" else "lora_input.yaml",
|
| 70 |
+
file_content=file_content,
|
| 71 |
+
iac_type=iac_type, # type: ignore[arg-type]
|
| 72 |
+
)
|
| 73 |
+
)
|
| 74 |
+
val = decision.validation
|
| 75 |
+
data = {
|
| 76 |
+
"original_file": file_content,
|
| 77 |
+
"fixed_file": decision.patch.fixed_file if decision.patch else "",
|
| 78 |
+
"decision": decision.model_dump(mode="json"),
|
| 79 |
+
"pre_rule_ids": [],
|
| 80 |
+
"post_rule_ids": val.targeted_unresolved if val else [],
|
| 81 |
+
"pre_summary": val.pre_summary if val else {},
|
| 82 |
+
"post_summary": val.post_summary if val else {},
|
| 83 |
+
}
|
| 84 |
+
return render_result(data)
|
| 85 |
+
except Exception as exc:
|
| 86 |
+
return (
|
| 87 |
+
file_content, "",
|
| 88 |
+
f"<div style='color:red;padding:8px'><b>LoRA inference error:</b> {exc}</div>",
|
| 89 |
+
"", "", "",
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _file_panels(label_orig: str, label_fixed: str, interactive_orig: bool = False) -> tuple:
|
| 94 |
+
orig = gr.Code(label=label_orig, language="yaml", interactive=interactive_orig, lines=22)
|
| 95 |
+
fixed = gr.Code(label=label_fixed, language="yaml", interactive=False, lines=22)
|
| 96 |
+
return orig, fixed
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _result_panels() -> tuple:
|
| 100 |
+
status = gr.HTML()
|
| 101 |
+
with gr.Row():
|
| 102 |
+
before = gr.Markdown(label="Findings Before")
|
| 103 |
+
after = gr.Markdown(label="Findings After")
|
| 104 |
+
details = gr.Markdown()
|
| 105 |
+
return status, before, after, details
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def build_demo() -> gr.Blocks:
|
| 109 |
+
demos = load_demos()
|
| 110 |
+
choices = list(demos.keys())
|
| 111 |
+
has_live_vllm = bool(os.getenv("IAC_SECFIX_BASE_URL"))
|
| 112 |
+
has_lora = has_hf_config()
|
| 113 |
+
|
| 114 |
+
with gr.Blocks(title="IaC-SecFix", theme=gr.themes.Soft()) as app:
|
| 115 |
+
gr.Markdown(
|
| 116 |
+
f"""
|
| 117 |
+
# IaC-SecFix
|
| 118 |
+
**Scanner-verified multi-agent remediation for Terraform and Kubernetes**
|
| 119 |
+
|
| 120 |
+
Multi-agent loop: **IngestAgent** β **PolicyContextAgent** β **PatchAgent** β
|
| 121 |
+
**ValidationAgent** β **SafetyAgent** β **HumanApprovalGate**
|
| 122 |
+
|
| 123 |
+
Fine-tuned LoRA: [`{LORA_MODEL_ID}`](https://huggingface.co/{LORA_MODEL_ID})
|
| 124 |
+
| Base model: `Qwen/Qwen3.5-9B` | r=8, alpha=16
|
| 125 |
+
| Built for the **AMD Developer Hackathon 2026**
|
| 126 |
+
"""
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
with gr.Tabs():
|
| 130 |
+
# ββ Tab 1: Demo Playback ββββββββββββββββββββββββββββββββββββββ
|
| 131 |
+
with gr.Tab("Demo Playback"):
|
| 132 |
+
gr.Markdown(
|
| 133 |
+
"Precomputed results from the benchmark run β no GPU required. "
|
| 134 |
+
"Select an example to see the full agent trace."
|
| 135 |
+
)
|
| 136 |
+
with gr.Row():
|
| 137 |
+
demo_dd = gr.Dropdown(choices=choices, value=choices[0] if choices else None,
|
| 138 |
+
label="Example", scale=4)
|
| 139 |
+
load_btn = gr.Button("Load", variant="primary", scale=1)
|
| 140 |
+
|
| 141 |
+
demo_status, demo_before, demo_after, demo_details = _result_panels()
|
| 142 |
+
with gr.Row():
|
| 143 |
+
demo_orig, demo_fixed = _file_panels("Original File", "Fixed File")
|
| 144 |
+
|
| 145 |
+
demo_outs = [demo_orig, demo_fixed, demo_status, demo_before, demo_after, demo_details]
|
| 146 |
+
load_btn.click(fn=lambda l: _demo_outputs(l, demos), inputs=[demo_dd], outputs=demo_outs)
|
| 147 |
+
app.load(fn=lambda: _demo_outputs(choices[0] if choices else None, demos), outputs=demo_outs)
|
| 148 |
+
|
| 149 |
+
# ββ Tab 2: Fine-tuned LoRA ββββββββββββββββββββββββββββββββββββ
|
| 150 |
+
if has_lora:
|
| 151 |
+
with gr.Tab("Fine-tuned LoRA"):
|
| 152 |
+
gr.Markdown(
|
| 153 |
+
f"Runs the full multi-agent loop using the fine-tuned LoRA adapter "
|
| 154 |
+
f"[`{LORA_MODEL_ID}`](https://huggingface.co/{LORA_MODEL_ID}).\n\n"
|
| 155 |
+
"**Space secrets required:** `HF_TOKEN` (serverless) "
|
| 156 |
+
"or `IAC_SECFIX_HF_ENDPOINT` + `IAC_SECFIX_HF_TOKEN` (dedicated endpoint)."
|
| 157 |
+
)
|
| 158 |
+
with gr.Row():
|
| 159 |
+
lora_code = gr.Code(label="Paste IaC file here", language="yaml",
|
| 160 |
+
interactive=True, lines=22)
|
| 161 |
+
lora_type = gr.Dropdown(choices=["terraform", "kubernetes"],
|
| 162 |
+
value="terraform", label="IaC Type", scale=0)
|
| 163 |
+
lora_btn = gr.Button("Run with Fine-tuned LoRA", variant="primary")
|
| 164 |
+
lora_status, lora_before, lora_after, lora_details = _result_panels()
|
| 165 |
+
with gr.Row():
|
| 166 |
+
lora_orig, lora_fixed = _file_panels("Input File", "Fixed File")
|
| 167 |
+
lora_btn.click(
|
| 168 |
+
fn=_lora_outputs,
|
| 169 |
+
inputs=[lora_code, lora_type],
|
| 170 |
+
outputs=[lora_orig, lora_fixed, lora_status, lora_before, lora_after, lora_details],
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
# ββ Tab 3: Live vLLM endpoint (optional) βββββββββββββββββββββ
|
| 174 |
+
if has_live_vllm:
|
| 175 |
+
with gr.Tab("Live vLLM Endpoint"):
|
| 176 |
+
gr.Markdown(
|
| 177 |
+
"Calls the OpenAI-compatible vLLM endpoint. "
|
| 178 |
+
"Set `IAC_SECFIX_BASE_URL`, `IAC_SECFIX_API_KEY`, and `IAC_SECFIX_MODEL` in Space secrets."
|
| 179 |
+
)
|
| 180 |
+
with gr.Row():
|
| 181 |
+
live_code = gr.Code(label="Paste IaC file here", language="yaml",
|
| 182 |
+
interactive=True, lines=22)
|
| 183 |
+
live_type = gr.Dropdown(choices=["terraform", "kubernetes"],
|
| 184 |
+
value="terraform", label="IaC Type", scale=0)
|
| 185 |
+
live_btn = gr.Button("Run Agent Loop", variant="primary")
|
| 186 |
+
live_status, live_before, live_after, live_details = _result_panels()
|
| 187 |
+
with gr.Row():
|
| 188 |
+
live_orig, live_fixed = _file_panels("Input File", "Fixed File")
|
| 189 |
+
live_btn.click(
|
| 190 |
+
fn=_live_outputs,
|
| 191 |
+
inputs=[live_code, live_type],
|
| 192 |
+
outputs=[live_orig, live_fixed, live_status, live_before, live_after, live_details],
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
gr.Markdown(
|
| 196 |
+
"---\n**IaC-SecFix** | AMD Developer Hackathon 2026 | "
|
| 197 |
+
"Benchmark B3 (zero-shot+RAG): 50 samples Β· 32.6% targeted resolution Β· 0% unsafe actions"
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
return app
|
requirements.txt
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
|
|
|
|
|
| 1 |
pydantic>=2.7
|
| 2 |
pydantic-ai-slim[openai]>=1.91.0
|
| 3 |
-
openai>=1.40.0
|
| 4 |
checkov>=3.2.0
|
| 5 |
python-hcl2>=4.3.0
|
| 6 |
PyYAML>=6.0.1
|
|
|
|
| 1 |
+
gradio>=5.0.0
|
| 2 |
+
openai>=1.40.0
|
| 3 |
pydantic>=2.7
|
| 4 |
pydantic-ai-slim[openai]>=1.91.0
|
|
|
|
| 5 |
checkov>=3.2.0
|
| 6 |
python-hcl2>=4.3.0
|
| 7 |
PyYAML>=6.0.1
|