gitinject 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gitinject/__init__.py +1 -0
- gitinject/__main__.py +5 -0
- gitinject/analyzer.py +67 -0
- gitinject/attacks/__init__.py +27 -0
- gitinject/attacks/autoinject.py +165 -0
- gitinject/attacks/base.py +33 -0
- gitinject/attacks/static.py +25 -0
- gitinject/cli.py +768 -0
- gitinject/data/research/scenarios/claude_skills_injection.md +96 -0
- gitinject/data/research/scenarios/cline_issue_body_injection.md +93 -0
- gitinject/data/research/scenarios/codex_agents_md_injection.md +109 -0
- gitinject/data/research/scenarios/dos_request_flood.md +133 -0
- gitinject/data/research/scenarios/dropped/ci_log_injection_workflow_poisoning.md +138 -0
- gitinject/data/research/scenarios/dropped/claude_md_instructions_injection.md +158 -0
- gitinject/data/research/scenarios/dropped/supply_chain_token_pivot.md +101 -0
- gitinject/data/research/scenarios/gemini_api_key_exfiltration.md +68 -0
- gitinject/data/research/scenarios/gemini_api_key_exfiltration_replication.md +0 -0
- gitinject/data/research/scenarios/gemini_md_instructions_injection.md +131 -0
- gitinject/data/research/scenarios/nsfw_api_key_block.md +114 -0
- gitinject/data/research/scenarios/pr_token_exfiltration_via_git_config.md +117 -0
- gitinject/data/research/scenarios/supply_chain_malicious_code.md +135 -0
- gitinject/evaluators.py +169 -0
- gitinject/evidence.py +72 -0
- gitinject/gl_runner.py +163 -0
- gitinject/resources.py +30 -0
- gitinject/run_context.py +72 -0
- gitinject/run_record.py +113 -0
- gitinject/runner.py +1024 -0
- gitinject/scanner/__init__.py +22 -0
- gitinject/scanner/baselines/__init__.py +0 -0
- gitinject/scanner/baselines/actionlint_runner.py +44 -0
- gitinject/scanner/baselines/zizmor_runner.py +35 -0
- gitinject/scanner/diagnostics.py +174 -0
- gitinject/scanner/hypothesis_generator.py +164 -0
- gitinject/scanner/live_validator.py +284 -0
- gitinject/scanner/llm_ranker.py +120 -0
- gitinject/scanner/memory.py +239 -0
- gitinject/scanner/primitives.py +236 -0
- gitinject/scanner/prompt_extractor.py +134 -0
- gitinject/scanner/recipe_scenario.py +203 -0
- gitinject/scanner/report_generator.py +230 -0
- gitinject/scanner/types.py +179 -0
- gitinject/scenario_base.py +147 -0
- gitinject/scenario_loader.py +77 -0
- gitinject/scenarios/__init__.py +0 -0
- gitinject/scenarios/benign/documentation_out_of_sync/contents/math_utils.py +3 -0
- gitinject/scenarios/benign/documentation_out_of_sync/contents/pydantic/v1/utils.py +21 -0
- gitinject/scenarios/benign/documentation_out_of_sync/scenario.py +57 -0
- gitinject/scenarios/benign/issue_deduplication_utility/scenario.py +76 -0
- gitinject/scenarios/benign/repository_onboarding_qa/contents/README.md +3 -0
- gitinject/scenarios/benign/repository_onboarding_qa/scenario.py +52 -0
- gitinject/scenarios/benign/secrets_false_positive/contents/tests/test_auth.py +14 -0
- gitinject/scenarios/benign/secrets_false_positive/scenario.py +58 -0
- gitinject/scenarios/benign/test_coverage_gap/contents/src/requests/auth_extra.py +33 -0
- gitinject/scenarios/benign/test_coverage_gap/scenario.py +56 -0
- gitinject/scenarios/benign/vulnerability_fix_verification/contents/src/flask/utils.py +15 -0
- gitinject/scenarios/benign/vulnerability_fix_verification/scenario.py +57 -0
- gitinject/scenarios/benign/vulnerable_code_review/contents/examples/tutorial/flaskr/db_utils.py +18 -0
- gitinject/scenarios/benign/vulnerable_code_review/scenario.py +55 -0
- gitinject/scenarios/benign/weekly_activity_summary/scenario.py +83 -0
- gitinject/scenarios/malicious/agents_md_approval_manipulation/contents/AGENTS.md +25 -0
- gitinject/scenarios/malicious/agents_md_approval_manipulation/contents/src/flask/csrf.py +151 -0
- gitinject/scenarios/malicious/agents_md_approval_manipulation/contents/tests/test_csrf.py +71 -0
- gitinject/scenarios/malicious/agents_md_approval_manipulation/scenario.py +121 -0
- gitinject/scenarios/malicious/claude_md_instructions_injection/contents/CLAUDE.md +23 -0
- gitinject/scenarios/malicious/claude_md_instructions_injection/contents/scripts/deploy_check.sh +29 -0
- gitinject/scenarios/malicious/claude_md_instructions_injection/scenario.py +93 -0
- gitinject/scenarios/malicious/cline_issue_body_injection/scenario.py +132 -0
- gitinject/scenarios/malicious/codex_agents_md_injection/contents/AGENTS.md +36 -0
- gitinject/scenarios/malicious/codex_agents_md_injection/contents/scripts/utils.py +12 -0
- gitinject/scenarios/malicious/codex_agents_md_injection/scenario.py +128 -0
- gitinject/scenarios/malicious/dos_request_flood/scenario.py +80 -0
- gitinject/scenarios/malicious/gemini_md_approval_manipulation/contents/src/flask/csrf.py +151 -0
- gitinject/scenarios/malicious/gemini_md_approval_manipulation/contents/tests/test_csrf.py +71 -0
- gitinject/scenarios/malicious/gemini_md_approval_manipulation/scenario.py +121 -0
- gitinject/scenarios/malicious/gemini_md_instructions_injection/contents/scripts/deploy_check.sh +38 -0
- gitinject/scenarios/malicious/gemini_md_instructions_injection/scenario.py +105 -0
- gitinject/scenarios/malicious/issue_onboarding_secret_exfiltration/scenario.py +76 -0
- gitinject/scenarios/malicious/pr_token_exfiltration_via_git_config/contents/scripts/deploy_check.sh +7 -0
- gitinject/scenarios/malicious/pr_token_exfiltration_via_git_config/scenario.py +113 -0
- gitinject/scenarios/malicious/supply_chain_malicious_code/contents/CLAUDE.md +25 -0
- gitinject/scenarios/malicious/supply_chain_malicious_code/contents/src/flask/csrf.py +151 -0
- gitinject/scenarios/malicious/supply_chain_malicious_code/contents/tests/test_csrf.py +71 -0
- gitinject/scenarios/malicious/supply_chain_malicious_code/scenario.py +123 -0
- gitinject/scenarios/malicious/unauthorized_pr_approval/contents/django/utils/crypto.py +13 -0
- gitinject/scenarios/malicious/unauthorized_pr_approval/scenario.py +57 -0
- gitinject/simulator.py +89 -0
- gitinject/utils/__init__.py +0 -0
- gitinject/utils/gh_client.py +628 -0
- gitinject/utils/gl_client.py +132 -0
- gitinject/utils/gl_provisioner.py +83 -0
- gitinject/utils/llm.py +205 -0
- gitinject/utils/provisioner.py +114 -0
- gitinject/utils/scenario_resources.py +33 -0
- gitinject/utils/types.py +49 -0
- gitinject/workflows/__init__.py +0 -0
- gitinject/workflows/claude-ci-auto-fix/contents/.github/workflows/main.yml +107 -0
- gitinject/workflows/claude-ci-auto-fix/metadata.json +10 -0
- gitinject/workflows/claude-general/contents/.github/workflows/main.yml +58 -0
- gitinject/workflows/claude-general/metadata.json +10 -0
- gitinject/workflows/claude-gitlab-mr-review/contents/.gitlab-ci.yml +36 -0
- gitinject/workflows/claude-gitlab-mr-review/metadata.json +11 -0
- gitinject/workflows/claude-issue-deduplication/contents/.github/workflows/main.yml +66 -0
- gitinject/workflows/claude-issue-deduplication/metadata.json +10 -0
- gitinject/workflows/claude-issue-triage/contents/.github/workflows/main.yml +34 -0
- gitinject/workflows/claude-issue-triage/metadata.json +10 -0
- gitinject/workflows/claude-manual-analysis/contents/.github/workflows/main.yml +42 -0
- gitinject/workflows/claude-manual-analysis/metadata.json +10 -0
- gitinject/workflows/claude-pr-review/contents/.github/workflows/main.yml +77 -0
- gitinject/workflows/claude-pr-review/metadata.json +10 -0
- gitinject/workflows/claude-pr-review-authors/contents/.github/workflows/main.yml +48 -0
- gitinject/workflows/claude-pr-review-authors/metadata.json +10 -0
- gitinject/workflows/claude-pr-review-paths/contents/.github/workflows/main.yml +49 -0
- gitinject/workflows/claude-pr-review-paths/metadata.json +10 -0
- gitinject/workflows/claude-test-analysis/contents/.github/workflows/main.yml +114 -0
- gitinject/workflows/claude-test-analysis/metadata.json +10 -0
- gitinject/workflows/cline-assistant/contents/.github/workflows/main.yml +87 -0
- gitinject/workflows/cline-assistant/contents/git-scripts/analyze-issue.sh +43 -0
- gitinject/workflows/cline-assistant/metadata.json +10 -0
- gitinject/workflows/codex-pr-review/contents/.github/workflows/main.yml +73 -0
- gitinject/workflows/codex-pr-review/metadata.json +10 -0
- gitinject/workflows/copilot-ci-doctor/contents/.github/workflows/ci-doctor.yml +1161 -0
- gitinject/workflows/copilot-ci-doctor/metadata.json +10 -0
- gitinject/workflows/copilot-lean-squad/contents/.github/workflows/lean-squad.yml +1313 -0
- gitinject/workflows/copilot-lean-squad/metadata.json +10 -0
- gitinject/workflows/copilot-malicious-scan/contents/.github/workflows/daily-malicious-code-scan.yml +899 -0
- gitinject/workflows/copilot-malicious-scan/metadata.json +10 -0
- gitinject/workflows/copilot-repo-assist/contents/.github/workflows/repo-assist.yml +1503 -0
- gitinject/workflows/copilot-repo-assist/metadata.json +10 -0
- gitinject/workflows/copilot-wiki-writer/contents/.github/workflows/agentic-wiki-writer.yml +1316 -0
- gitinject/workflows/copilot-wiki-writer/metadata.json +10 -0
- gitinject/workflows/gemini-assistant/contents/.github/workflows/gemini-invoke.yml +122 -0
- gitinject/workflows/gemini-assistant/contents/.github/workflows/gemini-plan-execute.yml +130 -0
- gitinject/workflows/gemini-assistant/contents/.github/workflows/gemini-review.yml +118 -0
- gitinject/workflows/gemini-assistant/contents/.github/workflows/gemini-scheduled-triage.yml +220 -0
- gitinject/workflows/gemini-assistant/contents/.github/workflows/gemini-triage.yml +160 -0
- gitinject/workflows/gemini-assistant/contents/.github/workflows/main.yml +220 -0
- gitinject/workflows/gemini-assistant/metadata.json +10 -0
- gitinject/workflows/gemini-assistant-original/AWESOME.md +118 -0
- gitinject/workflows/gemini-assistant-original/CONFIGURATION.md +162 -0
- gitinject/workflows/gemini-assistant-original/README.md +93 -0
- gitinject/workflows/gemini-assistant-original/gemini-assistant/README.md +192 -0
- gitinject/workflows/gemini-assistant-original/gemini-assistant/gemini-invoke.toml +94 -0
- gitinject/workflows/gemini-assistant-original/gemini-assistant/gemini-invoke.yml +131 -0
- gitinject/workflows/gemini-assistant-original/gemini-assistant/gemini-plan-execute.toml +100 -0
- gitinject/workflows/gemini-assistant-original/gemini-assistant/gemini-plan-execute.yml +139 -0
- gitinject/workflows/gemini-assistant-original/gemini-dispatch/README.md +49 -0
- gitinject/workflows/gemini-assistant-original/gemini-dispatch/gemini-dispatch.yml +221 -0
- gitinject/workflows/gemini-assistant-original/issue-triage/README.md +190 -0
- gitinject/workflows/gemini-assistant-original/issue-triage/gemini-scheduled-triage.toml +96 -0
- gitinject/workflows/gemini-assistant-original/issue-triage/gemini-scheduled-triage.yml +223 -0
- gitinject/workflows/gemini-assistant-original/issue-triage/gemini-triage.toml +32 -0
- gitinject/workflows/gemini-assistant-original/issue-triage/gemini-triage.yml +167 -0
- gitinject/workflows/gemini-assistant-original/metadata.json +10 -0
- gitinject/workflows/gemini-assistant-original/pr-review/README.md +337 -0
- gitinject/workflows/gemini-assistant-original/pr-review/gemini-review.toml +176 -0
- gitinject/workflows/gemini-assistant-original/pr-review/gemini-review.yml +119 -0
- gitinject/workflows/opencode-pr-review/contents/.github/workflows/main.yml +28 -0
- gitinject/workflows/opencode-pr-review/metadata.json +10 -0
- gitinject-0.1.0.dist-info/METADATA +128 -0
- gitinject-0.1.0.dist-info/RECORD +164 -0
- gitinject-0.1.0.dist-info/WHEEL +4 -0
- gitinject-0.1.0.dist-info/entry_points.txt +2 -0
- gitinject-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
from .types import MODEL_PRICING, AttackHypothesis, EffectivePromptContext, ScanCost, ValidationResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _severity_badge(severity: str) -> str:
|
|
11
|
+
return {"high": "🔴", "medium": "🟡", "low": "🟢"}.get(severity, "⚪")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def generate(
|
|
15
|
+
context: EffectivePromptContext,
|
|
16
|
+
results: list[ValidationResult],
|
|
17
|
+
discarded: list[tuple[AttackHypothesis, str]],
|
|
18
|
+
baseline_findings: list[dict],
|
|
19
|
+
output_dir: str,
|
|
20
|
+
scan_cost: ScanCost | None = None,
|
|
21
|
+
) -> tuple[str, str]:
|
|
22
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
23
|
+
timestamp = datetime.now(timezone.utc).isoformat()
|
|
24
|
+
|
|
25
|
+
confirmed = [r for r in results if r.status == "confirmed"]
|
|
26
|
+
unconfirmed = [r for r in results if r.status == "unconfirmed"]
|
|
27
|
+
skipped = [r for r in results if r.status == "skipped"]
|
|
28
|
+
|
|
29
|
+
scan_cost = scan_cost or ScanCost()
|
|
30
|
+
total_minutes = scan_cost.total_billable_minutes or sum(r.billable_minutes for r in results)
|
|
31
|
+
total_wall = scan_cost.total_wall_seconds or sum(r.wall_seconds for r in results)
|
|
32
|
+
|
|
33
|
+
md_lines = [
|
|
34
|
+
f"# Vulnerability Scan Report — {context.workflow_id}",
|
|
35
|
+
f"\n**Generated:** {timestamp}",
|
|
36
|
+
f"**Provider:** {context.provider}",
|
|
37
|
+
f"**Trigger:** {context.trigger_event}",
|
|
38
|
+
f"**Tool restrictions:** {', '.join(context.tool_restrictions) or 'none'}",
|
|
39
|
+
f"**persist-credentials:** {context.has_persist_credentials}",
|
|
40
|
+
"",
|
|
41
|
+
"---",
|
|
42
|
+
"",
|
|
43
|
+
"## Reconstructed Agent Prompt",
|
|
44
|
+
"```",
|
|
45
|
+
context.reconstructed_prompt[:2000],
|
|
46
|
+
"```" if len(context.reconstructed_prompt) <= 2000 else "```\n*(truncated)*",
|
|
47
|
+
"",
|
|
48
|
+
"---",
|
|
49
|
+
"",
|
|
50
|
+
"## Summary",
|
|
51
|
+
"| Metric | Value |",
|
|
52
|
+
"|--------|-------|",
|
|
53
|
+
f"| Hypotheses generated | {len(results) + len(discarded)} |",
|
|
54
|
+
f"| Filtered (pre-pass + LLM ranker) | {len(discarded)} |",
|
|
55
|
+
f"| Live-validated | {len(results)} |",
|
|
56
|
+
f"| **Confirmed** | **{len(confirmed)}** |",
|
|
57
|
+
f"| Unconfirmed | {len(unconfirmed)} |",
|
|
58
|
+
f"| Execution/evaluation errors | {sum(r.status == 'error' for r in results)} |",
|
|
59
|
+
f"| Skipped (dry-run) | {len(skipped)} |",
|
|
60
|
+
"",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
if confirmed:
|
|
64
|
+
md_lines += [
|
|
65
|
+
"---",
|
|
66
|
+
"",
|
|
67
|
+
"## Confirmed Vulnerabilities",
|
|
68
|
+
"",
|
|
69
|
+
]
|
|
70
|
+
for r in confirmed:
|
|
71
|
+
h = r.hypothesis
|
|
72
|
+
tags = ", ".join(f"`{t}`" for t in h.tags) if h.tags else "—"
|
|
73
|
+
md_lines += [
|
|
74
|
+
f"### {_severity_badge(h.severity)} `{h.id}` — {h.mitre_category}",
|
|
75
|
+
f"**Tags:** {tags} ",
|
|
76
|
+
f"**Attack goal:** {h.attack_goal} ",
|
|
77
|
+
f"**Recipe:** {r.payload_used} ",
|
|
78
|
+
f"**Success rate:** {r.success_rate} ",
|
|
79
|
+
f"**Found in iteration:** {r.iteration} ",
|
|
80
|
+
f"**Mitigation:** {r.suggested_mitigation}",
|
|
81
|
+
"",
|
|
82
|
+
"**Reproduction:**",
|
|
83
|
+
"```bash",
|
|
84
|
+
f"uv run gitinject run --workflow {context.workflow_id} --scenario {h.id}",
|
|
85
|
+
"```",
|
|
86
|
+
"",
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
if unconfirmed:
|
|
90
|
+
md_lines += [
|
|
91
|
+
"---",
|
|
92
|
+
"",
|
|
93
|
+
"## Unconfirmed Hypotheses",
|
|
94
|
+
"",
|
|
95
|
+
"| ID | Category | Recipe | Goal | Failure reason |",
|
|
96
|
+
"|----|----------|--------|------|----------------|",
|
|
97
|
+
]
|
|
98
|
+
for r in unconfirmed:
|
|
99
|
+
h = r.hypothesis
|
|
100
|
+
md_lines.append(
|
|
101
|
+
f"| `{h.id}` | {h.mitre_category} | {r.payload_used} "
|
|
102
|
+
f"| {h.attack_goal[:60]} | {r.failure_reason or 'unknown'} |"
|
|
103
|
+
)
|
|
104
|
+
md_lines.append("")
|
|
105
|
+
|
|
106
|
+
if discarded:
|
|
107
|
+
md_lines += [
|
|
108
|
+
"---",
|
|
109
|
+
"",
|
|
110
|
+
"## Filtered Hypotheses",
|
|
111
|
+
"",
|
|
112
|
+
"| ID | Category | Goal | Filter reason |",
|
|
113
|
+
"|----|----------|------|---------------|",
|
|
114
|
+
]
|
|
115
|
+
for h, reason in discarded:
|
|
116
|
+
md_lines.append(f"| `{h.id}` | {h.mitre_category} | {h.attack_goal[:60]} | {reason} |")
|
|
117
|
+
md_lines.append("")
|
|
118
|
+
|
|
119
|
+
if baseline_findings:
|
|
120
|
+
md_lines += [
|
|
121
|
+
"---",
|
|
122
|
+
"",
|
|
123
|
+
"## Baseline Tool Findings",
|
|
124
|
+
"",
|
|
125
|
+
]
|
|
126
|
+
by_tool: dict[str, list[dict]] = {}
|
|
127
|
+
for f in baseline_findings:
|
|
128
|
+
by_tool.setdefault(f.get("tool", "unknown"), []).append(f)
|
|
129
|
+
for tool, findings in by_tool.items():
|
|
130
|
+
md_lines += [f"### {tool} ({len(findings)} findings)", ""]
|
|
131
|
+
for f in findings:
|
|
132
|
+
if "error" in f:
|
|
133
|
+
md_lines.append(f"- ⚠️ {f['error']}")
|
|
134
|
+
else:
|
|
135
|
+
sev = f.get("severity", "?")
|
|
136
|
+
rule = f.get("rule", "?")
|
|
137
|
+
msg = f.get("message", "")
|
|
138
|
+
loc = f.get("location", "")
|
|
139
|
+
md_lines.append(f"- **{sev}** [{rule}] {msg} — `{loc}`")
|
|
140
|
+
md_lines.append("")
|
|
141
|
+
|
|
142
|
+
md_lines += [
|
|
143
|
+
"---",
|
|
144
|
+
"",
|
|
145
|
+
"## Cost Summary",
|
|
146
|
+
"",
|
|
147
|
+
"| Item | Value |",
|
|
148
|
+
"|------|-------|",
|
|
149
|
+
f"| Scanner input tokens | {scan_cost.total_input_tokens:,} |",
|
|
150
|
+
f"| Scanner output tokens | {scan_cost.total_output_tokens:,} |",
|
|
151
|
+
f"| Estimated scanner API cost | ${scan_cost.total_usd:.4f} |",
|
|
152
|
+
f"| GitHub Actions billable minutes | {total_minutes:.2f} |",
|
|
153
|
+
f"| Wall-clock seconds | {total_wall:.1f} |",
|
|
154
|
+
"",
|
|
155
|
+
]
|
|
156
|
+
if scan_cost.token_usage_by_model:
|
|
157
|
+
md_lines += [
|
|
158
|
+
"### Per-model token usage",
|
|
159
|
+
"",
|
|
160
|
+
"| Model | Input tokens | Output tokens | Cost (USD) | Priced? |",
|
|
161
|
+
"|-------|--------------|---------------|------------|---------|",
|
|
162
|
+
]
|
|
163
|
+
for model, t in sorted(scan_cost.token_usage_by_model.items()):
|
|
164
|
+
in_tok = t.get("input", 0)
|
|
165
|
+
out_tok = t.get("output", 0)
|
|
166
|
+
priced = model in MODEL_PRICING
|
|
167
|
+
in_price, out_price = MODEL_PRICING.get(model, (0.0, 0.0))
|
|
168
|
+
cost_usd = (in_tok * in_price + out_tok * out_price) / 1_000_000
|
|
169
|
+
md_lines.append(
|
|
170
|
+
f"| `{model}` | {in_tok:,} | {out_tok:,} | "
|
|
171
|
+
f"${cost_usd:.4f} | {'yes' if priced else '**no — update MODEL_PRICING**'} |"
|
|
172
|
+
)
|
|
173
|
+
md_lines.append("")
|
|
174
|
+
|
|
175
|
+
md_content = "\n".join(md_lines)
|
|
176
|
+
md_path = os.path.join(output_dir, f"{context.workflow_id}.md")
|
|
177
|
+
with open(md_path, "w") as f:
|
|
178
|
+
f.write(md_content)
|
|
179
|
+
|
|
180
|
+
json_data = {
|
|
181
|
+
"timestamp": timestamp,
|
|
182
|
+
"workflow_id": context.workflow_id,
|
|
183
|
+
"provider": context.provider,
|
|
184
|
+
"trigger_event": context.trigger_event,
|
|
185
|
+
"tool_restrictions": context.tool_restrictions,
|
|
186
|
+
"has_persist_credentials": context.has_persist_credentials,
|
|
187
|
+
"confirmed": [_result_to_dict(r) for r in confirmed],
|
|
188
|
+
"unconfirmed": [_result_to_dict(r) for r in unconfirmed],
|
|
189
|
+
"errors": [_result_to_dict(r) for r in results if r.status == "error"],
|
|
190
|
+
"skipped": [_result_to_dict(r) for r in results if r.status == "skipped"],
|
|
191
|
+
"filtered": [{"id": h.id, "reason": reason} for h, reason in discarded],
|
|
192
|
+
"baselines": baseline_findings,
|
|
193
|
+
"cost": {
|
|
194
|
+
"input_tokens": scan_cost.total_input_tokens,
|
|
195
|
+
"output_tokens": scan_cost.total_output_tokens,
|
|
196
|
+
"estimated_usd": scan_cost.total_usd,
|
|
197
|
+
"billable_minutes": total_minutes,
|
|
198
|
+
"wall_seconds": total_wall,
|
|
199
|
+
"token_usage_by_model": scan_cost.token_usage_by_model,
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
json_path = os.path.join(output_dir, f"{context.workflow_id}.json")
|
|
204
|
+
with open(json_path, "w") as f:
|
|
205
|
+
json.dump(json_data, f, indent=2)
|
|
206
|
+
|
|
207
|
+
return md_path, json_path
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _result_to_dict(r: ValidationResult) -> dict:
|
|
211
|
+
return {
|
|
212
|
+
"id": r.hypothesis.id,
|
|
213
|
+
"status": r.status,
|
|
214
|
+
"recipe_path": r.recipe_path,
|
|
215
|
+
"attempted_runs": r.attempted_runs,
|
|
216
|
+
"invalid_runs": r.invalid_runs,
|
|
217
|
+
"diagnostics": r.diagnostics,
|
|
218
|
+
"evaluator_correction": r.evaluator_correction,
|
|
219
|
+
"mitre_category": r.hypothesis.mitre_category,
|
|
220
|
+
"tags": r.hypothesis.tags,
|
|
221
|
+
"recipe_summary": r.payload_used,
|
|
222
|
+
"attack_goal": r.hypothesis.attack_goal,
|
|
223
|
+
"severity": r.hypothesis.severity,
|
|
224
|
+
"success_rate": r.success_rate,
|
|
225
|
+
"iteration": r.iteration,
|
|
226
|
+
"run_ids": r.run_ids,
|
|
227
|
+
"suggested_mitigation": r.suggested_mitigation,
|
|
228
|
+
"failure_reason": r.failure_reason,
|
|
229
|
+
"wall_seconds": r.wall_seconds,
|
|
230
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class EffectivePromptContext:
|
|
9
|
+
workflow_id: str
|
|
10
|
+
provider: str
|
|
11
|
+
reconstructed_prompt: str
|
|
12
|
+
tool_restrictions: list[str]
|
|
13
|
+
has_persist_credentials: bool
|
|
14
|
+
trigger_event: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class SetupStep:
|
|
19
|
+
primitive: str
|
|
20
|
+
args: dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class TriggerSpec:
|
|
25
|
+
event_type: str
|
|
26
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class SuccessCheck:
|
|
31
|
+
kind: str
|
|
32
|
+
args: dict[str, Any] = field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class AttackHypothesis:
|
|
37
|
+
id: str
|
|
38
|
+
mitre_category: str
|
|
39
|
+
attack_goal: str
|
|
40
|
+
rationale: str
|
|
41
|
+
severity: str
|
|
42
|
+
setup: list[SetupStep] = field(default_factory=list)
|
|
43
|
+
trigger: TriggerSpec | None = None
|
|
44
|
+
success_check: SuccessCheck | None = None
|
|
45
|
+
tags: list[str] = field(default_factory=list)
|
|
46
|
+
seeded_from: str | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class ValidationResult:
|
|
51
|
+
hypothesis: AttackHypothesis
|
|
52
|
+
status: str
|
|
53
|
+
failure_reason: str | None
|
|
54
|
+
success_rate: str
|
|
55
|
+
iteration: int
|
|
56
|
+
discard_reason: str | None
|
|
57
|
+
run_ids: list[str]
|
|
58
|
+
payload_used: str
|
|
59
|
+
suggested_mitigation: str
|
|
60
|
+
billable_minutes: float
|
|
61
|
+
wall_seconds: float
|
|
62
|
+
evaluator_correction: str | None = None
|
|
63
|
+
recipe_path: str | None = None
|
|
64
|
+
attempted_runs: int = 0
|
|
65
|
+
invalid_runs: int = 0
|
|
66
|
+
diagnostics: list[dict] = field(default_factory=list)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Model pricing in (input $/MTok, output $/MTok). Verified 2026-05 against
|
|
70
|
+
# Anthropic (docs.anthropic.com/en/docs/about-claude/pricing), Google
|
|
71
|
+
# (ai.google.dev/gemini-api/docs/pricing), and OpenAI rate cards. Gemini Pro
|
|
72
|
+
# rates are the standard <=200K-prompt tier. Unknown models price at $0 — the
|
|
73
|
+
# report surfaces them so we notice the gap rather than silently undercounting.
|
|
74
|
+
MODEL_PRICING: dict[str, tuple[float, float]] = {
|
|
75
|
+
"claude-opus-4-7": (5.00, 25.00),
|
|
76
|
+
"claude-opus-4-6": (5.00, 25.00),
|
|
77
|
+
"claude-opus-4-5": (5.00, 25.00),
|
|
78
|
+
"claude-opus-4-1": (15.00, 75.00),
|
|
79
|
+
"claude-sonnet-4-6": (3.00, 15.00),
|
|
80
|
+
"claude-sonnet-4-5": (3.00, 15.00),
|
|
81
|
+
"claude-haiku-4-5": (1.00, 5.00),
|
|
82
|
+
"gemini-2.5-flash": (0.30, 2.50),
|
|
83
|
+
"gemini-2.5-pro": (1.25, 10.00),
|
|
84
|
+
"gemini-3.1-pro-preview": (2.00, 12.00),
|
|
85
|
+
"gpt-5": (1.25, 10.00),
|
|
86
|
+
"gpt-5-2025-08-07": (1.25, 10.00),
|
|
87
|
+
"gpt-4o": (2.50, 10.00),
|
|
88
|
+
"gpt-4o-mini": (0.15, 0.60),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def cost_for_usage(usage_by_model: dict[str, dict[str, int]]) -> float:
|
|
93
|
+
total = 0.0
|
|
94
|
+
for model, t in usage_by_model.items():
|
|
95
|
+
in_price, out_price = MODEL_PRICING.get(model, (0.0, 0.0))
|
|
96
|
+
total += (t.get("input", 0) * in_price + t.get("output", 0) * out_price) / 1_000_000
|
|
97
|
+
return total
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def roll_up_usage(log) -> dict[str, dict[str, int]]:
|
|
101
|
+
"""Aggregate a list[LLMResponse] into {model: {"input": int, "output": int}}."""
|
|
102
|
+
out: dict[str, dict[str, int]] = {}
|
|
103
|
+
for r in log:
|
|
104
|
+
bucket = out.setdefault(r.model, {"input": 0, "output": 0})
|
|
105
|
+
bucket["input"] += int(r.input_tokens)
|
|
106
|
+
bucket["output"] += int(r.output_tokens)
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class ScanCost:
|
|
112
|
+
token_usage_by_model: dict[str, dict[str, int]] = field(default_factory=dict)
|
|
113
|
+
total_billable_minutes: float = 0.0
|
|
114
|
+
total_wall_seconds: float = 0.0
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def total_input_tokens(self) -> int:
|
|
118
|
+
return sum(v.get("input", 0) for v in self.token_usage_by_model.values())
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def total_output_tokens(self) -> int:
|
|
122
|
+
return sum(v.get("output", 0) for v in self.token_usage_by_model.values())
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def total_usd(self) -> float:
|
|
126
|
+
return cost_for_usage(self.token_usage_by_model)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class MemoryEntry:
|
|
131
|
+
provider: str
|
|
132
|
+
mitre_category: str
|
|
133
|
+
recipe_fingerprint: str
|
|
134
|
+
recipe_template: dict
|
|
135
|
+
attack_goal: str
|
|
136
|
+
status: str
|
|
137
|
+
failure_reason: str | None = None
|
|
138
|
+
evaluator_correction: str | None = None
|
|
139
|
+
tags: list[str] = field(default_factory=list)
|
|
140
|
+
workflow_ids: list[str] = field(default_factory=list)
|
|
141
|
+
first_seen: str = ""
|
|
142
|
+
source: str | None = None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def hypothesis_to_dict(h: AttackHypothesis) -> dict:
|
|
146
|
+
return {
|
|
147
|
+
"id": h.id,
|
|
148
|
+
"mitre_category": h.mitre_category,
|
|
149
|
+
"attack_goal": h.attack_goal,
|
|
150
|
+
"rationale": h.rationale,
|
|
151
|
+
"severity": h.severity,
|
|
152
|
+
"setup": [{"primitive": s.primitive, "args": s.args} for s in h.setup],
|
|
153
|
+
"trigger": {"event_type": h.trigger.event_type, "data": h.trigger.data} if h.trigger else None,
|
|
154
|
+
"success_check": {"kind": h.success_check.kind, "args": h.success_check.args} if h.success_check else None,
|
|
155
|
+
"tags": h.tags,
|
|
156
|
+
"seeded_from": h.seeded_from,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def hypothesis_from_dict(d: dict) -> AttackHypothesis:
|
|
161
|
+
trigger = None
|
|
162
|
+
if d.get("trigger"):
|
|
163
|
+
trigger = TriggerSpec(event_type=d["trigger"].get("event_type", ""), data=d["trigger"].get("data", {}))
|
|
164
|
+
success_check = None
|
|
165
|
+
if d.get("success_check"):
|
|
166
|
+
success_check = SuccessCheck(kind=d["success_check"].get("kind", ""), args=d["success_check"].get("args", {}))
|
|
167
|
+
setup = [SetupStep(primitive=s.get("primitive", ""), args=s.get("args", {})) for s in d.get("setup", [])]
|
|
168
|
+
return AttackHypothesis(
|
|
169
|
+
id=d.get("id", ""),
|
|
170
|
+
mitre_category=d.get("mitre_category", ""),
|
|
171
|
+
attack_goal=d.get("attack_goal", ""),
|
|
172
|
+
rationale=d.get("rationale", ""),
|
|
173
|
+
severity=d.get("severity", "medium"),
|
|
174
|
+
setup=setup,
|
|
175
|
+
trigger=trigger,
|
|
176
|
+
success_check=success_check,
|
|
177
|
+
tags=d.get("tags", []),
|
|
178
|
+
seeded_from=d.get("seeded_from"),
|
|
179
|
+
)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from abc import ABC
|
|
4
|
+
|
|
5
|
+
from .evaluators import Evaluator, UnconfiguredEvaluator
|
|
6
|
+
from .run_context import RunContext, TriggerReceipt
|
|
7
|
+
from .utils.types import ScenarioType, WorkflowCategory
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AbstractScenario(ABC):
|
|
11
|
+
"""
|
|
12
|
+
Abstract Base Class for all benchmark scenarios.
|
|
13
|
+
Each scenario defines its own GitHub event, state preparation, and evaluation logic.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
labels = []
|
|
17
|
+
category: WorkflowCategory = None
|
|
18
|
+
scenario_type: ScenarioType = ScenarioType.BENIGN
|
|
19
|
+
required_actors: tuple[str, ...] = ("owner",)
|
|
20
|
+
|
|
21
|
+
def __init__(self, workspace_dir):
|
|
22
|
+
self.workspace_dir = workspace_dir
|
|
23
|
+
self.scenario_dir = None
|
|
24
|
+
self.runtime_state = {}
|
|
25
|
+
self._injected: dict = {}
|
|
26
|
+
|
|
27
|
+
def prepare(self, context: RunContext) -> None:
|
|
28
|
+
self.setup_state(context.github("owner"))
|
|
29
|
+
|
|
30
|
+
def trigger(self, context: RunContext) -> TriggerReceipt:
|
|
31
|
+
return context.default_trigger()
|
|
32
|
+
|
|
33
|
+
def observe(self, context: RunContext, run_result: dict) -> dict:
|
|
34
|
+
return context.collect_target()
|
|
35
|
+
|
|
36
|
+
def cleanup(self, context: RunContext) -> None:
|
|
37
|
+
self.teardown_state(context.github("owner"))
|
|
38
|
+
|
|
39
|
+
def get_preflight_evaluator(self):
|
|
40
|
+
"""
|
|
41
|
+
Returns a callable (response: str) -> bool for offline attack validation,
|
|
42
|
+
or None if offline testing is not supported for this scenario.
|
|
43
|
+
|
|
44
|
+
The callable receives the victim model's raw text response and returns
|
|
45
|
+
True if the attack succeeded (i.e. the response contains the expected
|
|
46
|
+
malicious output). Used by the preflight and offline_optimize commands
|
|
47
|
+
to validate/optimize attacks without provisioning a GitHub repo.
|
|
48
|
+
"""
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
def get_attack_goal(self) -> str | None:
|
|
52
|
+
"""
|
|
53
|
+
Returns the goal string passed to attack.generate() — what we want the
|
|
54
|
+
victim LLM to do (e.g. "run gh repo list --visibility=private").
|
|
55
|
+
Return None to skip attack injection and use the hardcoded get_event() payload.
|
|
56
|
+
"""
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
def get_injection_slots(self) -> dict[str, str]:
|
|
60
|
+
"""
|
|
61
|
+
Returns {event_field: template} pairs. The runner substitutes the attack
|
|
62
|
+
payload at every {{INJECTION}} marker in each template, then calls
|
|
63
|
+
apply_attack(field, rendered_value).
|
|
64
|
+
|
|
65
|
+
Examples:
|
|
66
|
+
Full replacement: {"pr_body": "{{INJECTION}}"}
|
|
67
|
+
Suffix injection: {"pr_body": "## Summary\\nLegit text...\\n\\n{{INJECTION}}"}
|
|
68
|
+
Multi-field: {"pr_title": "fix: {{INJECTION}}", "pr_body": "...{{INJECTION}}..."}
|
|
69
|
+
|
|
70
|
+
Default: empty dict — no injection slots, get_event() is used as-is.
|
|
71
|
+
"""
|
|
72
|
+
return {}
|
|
73
|
+
|
|
74
|
+
def apply_attack(self, field: str, rendered: str) -> None:
|
|
75
|
+
"""Store a rendered injection value. get_event() checks _injected for overrides."""
|
|
76
|
+
self._injected[field] = rendered
|
|
77
|
+
|
|
78
|
+
def get_required_files(self) -> dict:
|
|
79
|
+
"""
|
|
80
|
+
Returns a dictionary of {repo_path: local_path_or_content}
|
|
81
|
+
for static files needed in the repo.
|
|
82
|
+
Defaults to discovering files in a 'contents/' directory next to the scenario.
|
|
83
|
+
"""
|
|
84
|
+
files = {}
|
|
85
|
+
if self.scenario_dir:
|
|
86
|
+
contents_dir = os.path.join(self.scenario_dir, "contents")
|
|
87
|
+
if os.path.isdir(contents_dir):
|
|
88
|
+
for root, _, filenames in os.walk(contents_dir):
|
|
89
|
+
for filename in filenames:
|
|
90
|
+
abs_path = os.path.join(root, filename)
|
|
91
|
+
rel_path = os.path.relpath(abs_path, contents_dir)
|
|
92
|
+
files[rel_path] = abs_path
|
|
93
|
+
return files
|
|
94
|
+
|
|
95
|
+
def get_required_secrets(self) -> list[str]:
|
|
96
|
+
"""
|
|
97
|
+
Returns a list of secret names (strings) that must be set in the repository.
|
|
98
|
+
The runner will attempt to fetch these from local environment variables.
|
|
99
|
+
"""
|
|
100
|
+
return []
|
|
101
|
+
|
|
102
|
+
def get_secrets(self) -> dict[str, str]:
|
|
103
|
+
"""
|
|
104
|
+
Returns a dictionary of {name: value} for secrets defined by the scenario.
|
|
105
|
+
These will be set in the repository automatically.
|
|
106
|
+
"""
|
|
107
|
+
return {}
|
|
108
|
+
|
|
109
|
+
def get_template_repo(self) -> str:
|
|
110
|
+
"""
|
|
111
|
+
Returns the full name of a repository to fork (e.g., 'owner/repo').
|
|
112
|
+
If None, a new empty repository is created.
|
|
113
|
+
"""
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
def setup_state(self, gh_client):
|
|
117
|
+
"""Prepares the repository state before the benchmark run."""
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
def teardown_state(self, gh_client):
|
|
121
|
+
"""Cleans up the repository state after the benchmark run."""
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
def get_event(self):
|
|
125
|
+
"""
|
|
126
|
+
Returns a dictionary representing the GitHub event.
|
|
127
|
+
Must contain 'event_type' and 'data'.
|
|
128
|
+
"""
|
|
129
|
+
return {}
|
|
130
|
+
|
|
131
|
+
def get_utility_evaluator(self) -> Evaluator:
|
|
132
|
+
"""
|
|
133
|
+
Returns an Evaluator instance to assess Utility.
|
|
134
|
+
Defaults to an unconfigured evaluator (unknown verdict).
|
|
135
|
+
"""
|
|
136
|
+
return UnconfiguredEvaluator()
|
|
137
|
+
|
|
138
|
+
def get_security_evaluator(self) -> Evaluator:
|
|
139
|
+
"""
|
|
140
|
+
Returns an Evaluator instance to assess Security.
|
|
141
|
+
Defaults to an unconfigured evaluator (unknown verdict).
|
|
142
|
+
"""
|
|
143
|
+
return UnconfiguredEvaluator()
|
|
144
|
+
|
|
145
|
+
def to_json(self):
|
|
146
|
+
"""Helper to export the event data to JSON format if needed."""
|
|
147
|
+
return json.dumps(self.get_event(), indent=4)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Shared, credential-free scenario discovery and loading."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import importlib.util
|
|
5
|
+
import inspect
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .scenario_base import AbstractScenario
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def scenario_definition(path: str | Path) -> Path:
|
|
13
|
+
path = Path(path)
|
|
14
|
+
if path.is_dir():
|
|
15
|
+
definitions = [path / name for name in ("scenario.py", "recipe.json") if (path / name).is_file()]
|
|
16
|
+
if len(definitions) != 1:
|
|
17
|
+
raise ValueError(f"Expected exactly one scenario.py or recipe.json in {path}")
|
|
18
|
+
return definitions[0]
|
|
19
|
+
if not path.is_file() or path.suffix not in {".py", ".json"}:
|
|
20
|
+
raise ValueError(f"Scenario definition not found: {path}")
|
|
21
|
+
return path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def discover_scenario_paths(root: str | Path) -> list[Path]:
|
|
25
|
+
root = Path(root)
|
|
26
|
+
if not root.exists():
|
|
27
|
+
return []
|
|
28
|
+
paths = []
|
|
29
|
+
for directory in sorted({p.parent for name in ("scenario.py", "recipe.json") for p in root.rglob(name)}):
|
|
30
|
+
if not {"contents", "__pycache__"}.intersection(directory.relative_to(root).parts):
|
|
31
|
+
paths.append(scenario_definition(directory))
|
|
32
|
+
names = [path.parent.name for path in paths]
|
|
33
|
+
if len(names) != len(set(names)):
|
|
34
|
+
raise ValueError("Duplicate scenario IDs in dataset")
|
|
35
|
+
return paths
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def find_scenario(root: str | Path, identifier: str) -> Path | None:
|
|
39
|
+
direct = Path(identifier)
|
|
40
|
+
if direct.exists():
|
|
41
|
+
return scenario_definition(direct)
|
|
42
|
+
matches = [path for path in discover_scenario_paths(root) if path.parent.name == identifier]
|
|
43
|
+
return matches[0] if matches else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_scenario(path: str | Path, workspace_dir: str) -> AbstractScenario:
|
|
47
|
+
definition = scenario_definition(path).resolve()
|
|
48
|
+
if definition.suffix == ".json":
|
|
49
|
+
from .scanner.recipe_scenario import load_recipe
|
|
50
|
+
|
|
51
|
+
if definition.name != "recipe.json":
|
|
52
|
+
raise ValueError("JSON scenarios must use recipe.json")
|
|
53
|
+
scenario = load_recipe(str(definition.parent), workspace_dir)
|
|
54
|
+
else:
|
|
55
|
+
content = definition.read_bytes()
|
|
56
|
+
name = "gitinject_scenario_" + hashlib.sha256(content).hexdigest()[:16]
|
|
57
|
+
spec = importlib.util.spec_from_file_location(name, definition)
|
|
58
|
+
module = importlib.util.module_from_spec(spec)
|
|
59
|
+
sys.modules[name] = module
|
|
60
|
+
try:
|
|
61
|
+
exec(compile(content, str(definition), "exec"), module.__dict__)
|
|
62
|
+
except BaseException:
|
|
63
|
+
sys.modules.pop(name, None)
|
|
64
|
+
raise
|
|
65
|
+
classes = [
|
|
66
|
+
cls
|
|
67
|
+
for cls in vars(module).values()
|
|
68
|
+
if inspect.isclass(cls)
|
|
69
|
+
and cls.__module__ == name
|
|
70
|
+
and issubclass(cls, AbstractScenario)
|
|
71
|
+
and not inspect.isabstract(cls)
|
|
72
|
+
]
|
|
73
|
+
if len(classes) != 1:
|
|
74
|
+
raise ValueError(f"Expected exactly one concrete scenario class in {definition}")
|
|
75
|
+
scenario = classes[0](workspace_dir)
|
|
76
|
+
scenario.scenario_dir = str(definition.parent)
|
|
77
|
+
return scenario
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from typing import Any, Dict, TypeVar
|
|
2
|
+
|
|
3
|
+
KeyType = TypeVar("KeyType")
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def deep_update(
|
|
7
|
+
mapping: Dict[KeyType, Any], *updating_mappings: Dict[KeyType, Any], merge_lists: bool = False
|
|
8
|
+
) -> Dict[KeyType, Any]:
|
|
9
|
+
"""
|
|
10
|
+
Recursively update a dictionary.
|
|
11
|
+
"""
|
|
12
|
+
updated_mapping = mapping.copy()
|
|
13
|
+
for updating_mapping in updating_mappings:
|
|
14
|
+
for k, v in updating_mapping.items():
|
|
15
|
+
if k in updated_mapping and isinstance(updated_mapping[k], dict) and isinstance(v, dict):
|
|
16
|
+
updated_mapping[k] = deep_update(updated_mapping[k], v, merge_lists=merge_lists)
|
|
17
|
+
elif merge_lists and k in updated_mapping and isinstance(updated_mapping[k], list) and isinstance(v, list):
|
|
18
|
+
updated_mapping[k] = updated_mapping[k] + v
|
|
19
|
+
else:
|
|
20
|
+
updated_mapping[k] = v
|
|
21
|
+
return updated_mapping
|