selcuksntrk's picture
Add IaC-SecFix agent package
0953e56 verified
Raw
History Blame Contribute Delete
11 kB
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from .config import RuntimeConfig
from .llm import OpenAICompatibleLLM, make_pydantic_agent, make_pydantic_openai_model, parse_fix_candidate
from .policy import build_rule_hints_md, enrich_findings
from .schemas import (
ApprovalDecision,
ComplianceReport,
EnrichedFindings,
Finding,
FixCandidate,
IngestRequest,
ScanBundle,
ValidationResult,
)
@dataclass(slots=True)
class PydanticAgentBundle:
ingest: Any | None = None
policy_context: Any | None = None
patch: Any | None = None
validation: Any | None = None
compliance: Any | None = None
approval: Any | None = None
@property
def available(self) -> bool:
return any(
agent is not None
for agent in [
self.ingest,
self.policy_context,
self.patch,
self.validation,
self.compliance,
self.approval,
]
)
def create_pydantic_agent_bundle(config: RuntimeConfig) -> PydanticAgentBundle:
"""Build PydanticAI agents when the installed API supports it."""
model = make_pydantic_openai_model(config)
return PydanticAgentBundle(
ingest=make_pydantic_agent(
model,
ScanBundle,
"Identify file type and return scanner bundle. Deterministic scanner tools supply findings.",
),
policy_context=make_pydantic_agent(
model,
EnrichedFindings,
"Map scanner findings to policy descriptions. Do not invent policy IDs.",
),
patch=make_pydantic_agent(model, FixCandidate, "Return only a valid FixCandidate JSON object."),
validation=make_pydantic_agent(model, ValidationResult, "Summarize deterministic validation results only."),
compliance=make_pydantic_agent(model, ComplianceReport, "Compute compliance summary only from scan summaries."),
approval=make_pydantic_agent(
model,
ApprovalDecision,
"Return needs_human_review unless deterministic validation says accept and auto approval is enabled.",
),
)
def shard_for_context(file_content: str, findings: list[Finding], max_chars: int) -> str:
if len(file_content) <= max_chars:
return file_content
lines = file_content.splitlines()
selected: list[str] = ["# Non-targeted blocks omitted for context budget."]
used_ranges: list[tuple[int, int]] = []
for finding in findings:
start = max((finding.line_start or 1) - 20, 1)
end = min((finding.line_end or start) + 20, len(lines))
if any(not (end < old_start or start > old_end) for old_start, old_end in used_ranges):
continue
used_ranges.append((start, end))
selected.append(f"\n# Context lines {start}-{end}")
selected.extend(lines[start - 1 : end])
if len("\n".join(selected)) >= max_chars:
break
return "\n".join(selected)[:max_chars]
def fix_candidate_schema_text() -> str:
return """
Return exactly one JSON object:
{
"patch": "unified diff string",
"fixed_file": "complete corrected file content",
"resolved_policy_ids": ["CKV_..."],
"explanation": "short summary",
"verification_commands": ["checkov -f <file> -o json"],
"risk_notes": ["human-review note if needed"],
"requires_human_approval": true
}
No markdown fences. No prose outside JSON.
""".strip()
def build_patch_prompt(
enriched: EnrichedFindings,
retry_feedback: str | None = None,
max_context_chars: int = 18000,
max_finding_records: int = 24,
) -> str:
iac_type = enriched.file.iac_type
lang = "hcl" if iac_type == "terraform" else "yaml"
findings_for_json = enriched.findings[:max_finding_records]
omitted = max(0, len(enriched.findings) - len(findings_for_json))
scanner_report_json = json.dumps(
[finding.model_dump(mode="json") for finding in findings_for_json],
ensure_ascii=False,
indent=2,
)
rule_ids = [finding.rule_id for finding in enriched.findings]
original_context = shard_for_context(enriched.file.file_content, list(enriched.findings), max_context_chars)
prompt = f"""
Original file type: {iac_type}
Original file language: {lang}
<original_file>
{original_context}
</original_file>
<targeted_rule_ids>
{json.dumps(sorted(set(rule_ids)))}
</targeted_rule_ids>
<scanner_findings_json>
{scanner_report_json}
</scanner_findings_json>
<scanner_findings_omitted_due_context_budget>
{omitted}
</scanner_findings_omitted_due_context_budget>
<targeted_rules_with_fix_templates>
{build_rule_hints_md(rule_ids)}
</targeted_rules_with_fix_templates>
Task:
Create the smallest safe in-file remediation that resolves as many targeted rule_id values as possible.
Hard requirements:
- Start from the ORIGINAL file. Add or modify only attributes directly needed by the matching fix templates.
- fixed_file must contain the complete corrected file, including unchanged lines.
- Do not delete, rename, or reorder resources, modules, providers, containers, or attributes unless the rule requires it.
- Do not invent placeholder ARNs, account IDs, bucket names, topics, roles, regions, KMS keys, CIDRs, or image tags.
- If a rule requires an external dependency or unknown business decision, leave it unresolved and set requires_human_approval=true.
- Do not introduce CRITICAL or HIGH findings.
- Do not add checkov:skip, nosec, tfsec:ignore, or scanner suppression comments.
- Before returning, list only rule IDs that your fixed_file actually resolves in resolved_policy_ids.
{fix_candidate_schema_text()}
""".strip()
if retry_feedback:
prompt += f"\n\n<previous_validation_feedback>\n{retry_feedback[:4000]}\n</previous_validation_feedback>"
return prompt
def compress_feedback(result: ValidationResult, attempt: int, max_retries: int) -> str:
payload = {
"attempt": attempt + 1,
"max_retries": max_retries,
"retry_reason": result.retry_reason,
"targeted_still_unresolved": result.targeted_unresolved,
"new_critical_high_count": result.new_critical_high_count,
"changed_lines_ratio": round(result.changed_lines_ratio, 3),
"post_scan_summary": result.post_summary,
}
feedback = json.dumps(payload, ensure_ascii=False)
if result.targeted_unresolved:
feedback += "\n\nUnresolved-rule fix templates:\n"
feedback += build_rule_hints_md(result.targeted_unresolved)
feedback += (
"\n\nRetry instructions:\n"
"- Start again from the original file, not the previous attempt.\n"
"- Apply the smallest safe edit for the unresolved rules.\n"
"- Remove rule IDs from resolved_policy_ids when validation did not resolve them.\n"
)
return feedback[:4000]
class IngestAgent:
def run(self, request: IngestRequest, tmp_dir: Path | None = None) -> ScanBundle:
from .scanners import unified_scan
return unified_scan(request, tmp_dir=tmp_dir)
class PolicyContextAgent:
def run(self, bundle: ScanBundle) -> EnrichedFindings:
return enrich_findings(bundle)
class PatchAgent:
def __init__(self, config: RuntimeConfig, log_dir: Path):
self.llm = OpenAICompatibleLLM(config)
self.log_dir = log_dir
self.log_dir.mkdir(parents=True, exist_ok=True)
def run(
self,
enriched: EnrichedFindings,
retry_feedback: str | None = None,
max_context_chars: int = 18000,
) -> tuple[FixCandidate | None, bool, str]:
prompt = build_patch_prompt(enriched, retry_feedback, max_context_chars=max_context_chars)
messages = [
{
"role": "system",
"content": (
"You are IaC-SecFix. Return only valid JSON matching the FixCandidate schema. "
"Do not include markdown, prose, analysis, or code fences."
),
},
{"role": "user", "content": prompt},
]
result = self.llm.chat(messages, json_mode=True)
self.log_dir.joinpath("last_patch_raw.txt").write_text(result.text[:20000], errors="ignore")
candidate, schema_valid = parse_fix_candidate(result.text)
return candidate, schema_valid, result.text
class ValidationAgent:
def run(
self,
request: IngestRequest,
candidate: FixCandidate,
targeted: list[str],
tmp_dir: Path | None,
max_changed_lines_ratio: float,
) -> ValidationResult:
from .scanners import validate_candidate
return validate_candidate(
request,
candidate,
targeted,
tmp_dir=tmp_dir,
max_changed_lines_ratio=max_changed_lines_ratio,
)
class ComplianceAgent:
def run(self, pre_summary: dict[str, object], result: ValidationResult) -> ComplianceReport:
before_failed = max(int(pre_summary.get("failed", 0) or 0), len(result.targeted_resolved) + len(result.targeted_unresolved), 1)
after_failed = int(result.post_summary.get("failed", 0) or 0)
targeted_total = max(len(result.targeted_resolved) + len(result.targeted_unresolved), 1)
return ComplianceReport(
cis_pass_rate_before=max(0.0, 1.0 - before_failed / max(before_failed, 1)),
cis_pass_rate_after=max(0.0, 1.0 - after_failed / max(before_failed, 1)),
network_exposure_reduction=1.0 if result.targeted_resolved else 0.0,
iam_privilege_reduction=1.0 if result.targeted_resolved else 0.0,
targeted_resolution_rate=len(result.targeted_resolved) / targeted_total,
scanner_clean=after_failed == 0,
)
class HumanApprovalGate:
def __init__(self, auto_approve_validated: bool = False):
self.auto_approve_validated = auto_approve_validated
def run(
self,
patch: FixCandidate,
result: ValidationResult,
compliance: ComplianceReport,
n_retries: int,
) -> ApprovalDecision:
can_auto_approve = (
self.auto_approve_validated
and result.verdict == "accept"
and not patch.requires_human_approval
)
if can_auto_approve:
return ApprovalDecision(
action="approved",
reason="Deterministic validation accepted the patch.",
patch=patch,
validation=result,
compliance=compliance,
n_retries=n_retries,
)
return ApprovalDecision(
action="needs_human_review",
reason="Validated candidate requires human approval before export.",
patch=patch,
validation=result,
compliance=compliance,
n_retries=n_retries,
)