selcuksntrk commited on
Commit
f66d4b5
Β·
verified Β·
1 Parent(s): 0953e56

Add IaC-SecFix app with Gradio UI and agent backend

Browse files
README.md CHANGED
@@ -1,131 +1,82 @@
1
- # IaC-SecFix Agents
 
 
 
 
 
 
 
 
 
2
 
3
- Submission-ready reference backend for the IaC-SecFix hackathon project.
4
 
5
- The Hugging Face Space in `../hf_space_iac_secfix` is the public UI. This package is the fuller agentic backend/reference implementation: it can run Checkov, parse Terraform/Kubernetes files, call an OpenAI-compatible Qwen/vLLM endpoint, retry with validation feedback, and emit a typed human-review decision.
6
 
7
- ## What This Package Is
8
 
9
- IaC-SecFix is a scanner-verified multi-agent remediation system for Terraform and Kubernetes.
10
 
11
- It keeps the LLM useful but bounded:
 
12
 
13
- - graph/control flow is deterministic in `RemediationOrchestrator`
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
- ## Current Deployment Reality
20
 
21
- You do not need AMD MI300X access to publish the demo.
22
 
23
- - Use the Hugging Face Space in CPU mode for the UI and deterministic demo playback.
24
- - Upload the LoRA adapter to a Hugging Face model repo for reproducibility.
25
- - Use a live Qwen/vLLM endpoint only when you have GPU access again.
26
- - If no GPU endpoint is available, the Space remains submission-safe in demo playback mode.
27
 
28
- ## Agent Graph
29
 
30
- 1. `IngestAgent`: detects IaC type and runs Checkov.
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
- ## Evidence Summary
 
 
 
39
 
40
- The strongest complete benchmark claim remains **B3: zero-shot + RAG**:
41
 
42
- - n=50 benchmark rows
43
- - scanner clean rate: 14%
44
- - targeted policy resolution: 32.59%
45
- - schema validity: 88%
46
- - parse validity: 88%
47
- - unsafe action rate: 0%
 
 
 
48
 
49
- The agent-loop system, **B5**, is the product direction but only partial:
50
 
51
- - n=28 partial benchmark rows
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
- Use B3 as the complete benchmark claim and B5 as promising agentic architecture evidence until a full B5 rerun completes.
59
 
60
- ## Install Locally
 
 
 
 
 
 
 
61
 
62
- ```bash
63
- cd outputs/iac_secfix_agents
64
- python -m venv .venv
65
- source .venv/bin/activate
66
- pip install -e .
67
- ```
68
 
69
- Copy the environment template:
70
 
71
- ```bash
72
- cp .env.example .env
73
- ```
74
 
75
- Set these values if you have a live endpoint:
 
 
76
 
77
- ```bash
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
- ## Run One File
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