flowsec 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.
- flowsec-0.1.0.dist-info/METADATA +206 -0
- flowsec-0.1.0.dist-info/RECORD +26 -0
- flowsec-0.1.0.dist-info/WHEEL +4 -0
- flowsec-0.1.0.dist-info/entry_points.txt +2 -0
- pipelineguard/__init__.py +0 -0
- pipelineguard/ai_narrative.py +73 -0
- pipelineguard/cli.py +141 -0
- pipelineguard/parser.py +33 -0
- pipelineguard/report.py +35 -0
- pipelineguard/rules/__init__.py +0 -0
- pipelineguard/rules/artifact_signing.py +63 -0
- pipelineguard/rules/base.py +37 -0
- pipelineguard/rules/dependency_pinning.py +72 -0
- pipelineguard/rules/excessive_permissions.py +40 -0
- pipelineguard/rules/hardcoded_secrets.py +119 -0
- pipelineguard/rules/missing_branch_protection.py +15 -0
- pipelineguard/rules/missing_env_protection.py +33 -0
- pipelineguard/rules/missing_oidc.py +56 -0
- pipelineguard/rules/missing_timeout.py +65 -0
- pipelineguard/rules/pull_request_target.py +58 -0
- pipelineguard/rules/secrets_in_run.py +64 -0
- pipelineguard/rules/self_hosted_runner.py +79 -0
- pipelineguard/rules/unpinned_actions.py +45 -0
- pipelineguard/rules/workflow_dispatch_injection.py +44 -0
- pipelineguard/scanner.py +84 -0
- pipelineguard/scoring.py +0 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flowsec
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CI/CD pipeline security analyzer — scans GitHub Actions, GitLab CI, and Azure DevOps configs for attack vectors
|
|
5
|
+
Author-email: Vansh Bhardwaj <Scorpio.vansh@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: cicd,devsecops,github-actions,pipeline-security,sast,security
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Topic :: Security
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Requires-Dist: anthropic>=0.28.0
|
|
15
|
+
Requires-Dist: httpx>=0.27.0
|
|
16
|
+
Requires-Dist: jinja2>=3.1.4
|
|
17
|
+
Requires-Dist: jsonpath-ng>=1.6.1
|
|
18
|
+
Requires-Dist: pygithub>=2.1.1
|
|
19
|
+
Requires-Dist: pygments>=2.18.0
|
|
20
|
+
Requires-Dist: python-dotenv>=1.0.1
|
|
21
|
+
Requires-Dist: python-gitlab>=4.4.0
|
|
22
|
+
Requires-Dist: pyyaml>=6.0.1
|
|
23
|
+
Requires-Dist: rich>=13.7.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: bandit>=1.7.8; extra == 'dev'
|
|
26
|
+
Requires-Dist: hatch>=1.12.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: mypy>=1.10.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: pre-commit>=3.7.1; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-cov>=5.0.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest-mock>=3.14.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=8.2.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: responses>=0.25.3; extra == 'dev'
|
|
33
|
+
Requires-Dist: ruff>=0.4.4; extra == 'dev'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# FlowSec
|
|
37
|
+
|
|
38
|
+
> A Python security tool that scans CI/CD pipeline configurations for attack vectors across GitHub Actions, GitLab CI, and Azure DevOps. Every finding maps to a MITRE ATT&CK technique and OWASP CICD Top 10 category.
|
|
39
|
+
>
|
|
40
|
+
> The pipeline is the attack surface. FlowSec treats it that way.
|
|
41
|
+
|
|
42
|
+
**[Full documentation](docs/FULL_README.md)**
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
[](https://codespaces.new/VanshBhardwaj1945/FlowSec)
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## What it catches
|
|
51
|
+
|
|
52
|
+
| ID | Rule | Severity | MITRE | OWASP |
|
|
53
|
+
|---|---|---|---|---|
|
|
54
|
+
| FS001 | Hardcoded Secret — Plaintext Credential in Workflow | CRITICAL | T1552.001 | CICD-SEC-6 |
|
|
55
|
+
| FS002 | Unpinned Action — Supply Chain Attack Vector | CRITICAL | T1195.001 | CICD-SEC-3 |
|
|
56
|
+
| FS003 | Excessive Permissions — Overprivileged Workflow Token | HIGH | T1078 | CICD-SEC-5 |
|
|
57
|
+
| FS004 | Missing OIDC — Long-Lived Cloud Credential in Use | HIGH | T1552.004 | CICD-SEC-6 |
|
|
58
|
+
| FS005 | Pull Request Target — Secrets Exposed to Fork Code | CRITICAL | T1611 | CICD-SEC-4 |
|
|
59
|
+
| FS006 | Missing Timeout — Job Runs Up to 6 Hours Unchecked | LOW | T1499 | CICD-SEC-10 |
|
|
60
|
+
| FS007 | Self-Hosted Runner — Persistent Environment Risk | HIGH | T1053 | CICD-SEC-7 |
|
|
61
|
+
| FS008 | Missing Artifact Signing — No Tamper Protection | MEDIUM | T1553 | CICD-SEC-8 |
|
|
62
|
+
| FS009 | Unpinned Dependency — Package Installed Without Version Lock | HIGH | T1195.002 | CICD-SEC-3 |
|
|
63
|
+
| FS010 | Secret in Run Command — Plaintext Credential in Shell Step | CRITICAL | T1552.001 | CICD-SEC-6 |
|
|
64
|
+
| FS011 | Missing Branch Protection — Direct Push to Default Branch Possible | HIGH | T1098 | CICD-SEC-1 |
|
|
65
|
+
| FS012 | Missing Environment Protection — Deploy Job Has No Approval Gate | HIGH | T1078 | CICD-SEC-5 |
|
|
66
|
+
| FS013 | Workflow Dispatch Injection — Unvalidated Input in Shell Command | CRITICAL | T1059 | CICD-SEC-9 |
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Platforms supported
|
|
71
|
+
|
|
72
|
+
| Platform | How to scan |
|
|
73
|
+
|---|---|
|
|
74
|
+
| GitHub Actions | `flowsec scan --repo owner/repo` or `--file workflow.yml` |
|
|
75
|
+
| GitLab CI | `flowsec scan --gitlab .gitlab-ci.yml` |
|
|
76
|
+
| Azure DevOps | `flowsec scan --azure azure-pipelines.yml` |
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Real findings on a real repo
|
|
81
|
+
|
|
82
|
+
Scanned `VanshBhardwaj1945/cloud-resume-challenge-azure` — 13 findings across 2 workflow files:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
[CRITICAL] FS002 - Unpinned Action — Supply Chain Attack Vector
|
|
86
|
+
File: .github/workflows/backend.main.yaml
|
|
87
|
+
Action 'actions/checkout@v4' is not pinned to a commit hash
|
|
88
|
+
|
|
89
|
+
[HIGH] FS003 - Excessive Permissions — Overprivileged Workflow Token
|
|
90
|
+
File: .github/workflows/backend.main.yaml
|
|
91
|
+
Pipeline permissions set to 'None' — GitHub defaults apply
|
|
92
|
+
|
|
93
|
+
[HIGH] FS004 - Missing OIDC — Long-Lived Cloud Credential in Use
|
|
94
|
+
File: .github/workflows/backend.main.yaml
|
|
95
|
+
Cloud provider action present but no id-token: write permission
|
|
96
|
+
|
|
97
|
+
[LOW] FS006 - Missing Timeout — Job Runs Up to 6 Hours Unchecked
|
|
98
|
+
File: .github/workflows/backend.main.yaml
|
|
99
|
+
Job 'build-and-deploy' has no timeout — GitHub default is 6 hours
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Stack
|
|
105
|
+
|
|
106
|
+
| Layer | Tools |
|
|
107
|
+
|---|---|
|
|
108
|
+
| Language | Python 3.11 |
|
|
109
|
+
| GitHub Connection | PyGithub |
|
|
110
|
+
| YAML Parsing | PyYAML with custom line-tracking loader |
|
|
111
|
+
| Terminal Output | rich |
|
|
112
|
+
| HTML Reports | Jinja2 — interactive filtering, expandable findings, PDF export |
|
|
113
|
+
| AI Narratives | Anthropic Claude API with local caching |
|
|
114
|
+
| Linting | ruff, mypy, bandit |
|
|
115
|
+
| Packaging | hatch, pyproject.toml |
|
|
116
|
+
| Containerization | Docker |
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Quick Start
|
|
121
|
+
|
|
122
|
+
Click the Codespaces button above to run FlowSec in your browser with zero setup. Or run locally:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
git clone https://github.com/VanshBhardwaj1945/FlowSec.git
|
|
126
|
+
cd FlowSec
|
|
127
|
+
python3 -m venv .venv
|
|
128
|
+
source .venv/bin/activate
|
|
129
|
+
pip install -e ".[dev]"
|
|
130
|
+
cp .env.example .env
|
|
131
|
+
# Add your GITHUB_TOKEN to .env
|
|
132
|
+
|
|
133
|
+
flowsec scan --repo owner/repo
|
|
134
|
+
flowsec scan --file .github/workflows/ci.yml
|
|
135
|
+
flowsec scan --gitlab .gitlab-ci.yml
|
|
136
|
+
flowsec scan --azure azure-pipelines.yml
|
|
137
|
+
flowsec scan --repo owner/repo --ai --output report.html
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Use as a Pipeline Gate
|
|
143
|
+
|
|
144
|
+
Add FlowSec to your own GitHub Actions workflow to automatically block PRs that introduce security misconfigurations:
|
|
145
|
+
|
|
146
|
+
```yaml
|
|
147
|
+
name: FlowSec Security Scan
|
|
148
|
+
on: [push, pull_request]
|
|
149
|
+
jobs:
|
|
150
|
+
security:
|
|
151
|
+
runs-on: ubuntu-latest
|
|
152
|
+
steps:
|
|
153
|
+
- uses: actions/checkout@v3
|
|
154
|
+
- name: Install FlowSec
|
|
155
|
+
run: pip install flowsec
|
|
156
|
+
- name: Run FlowSec
|
|
157
|
+
run: flowsec scan --file .github/workflows/ --fail-on critical
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`--fail-on critical` exits with code 1 if any CRITICAL findings are found, failing the pipeline and blocking the PR. Supports `critical`, `high`, `medium`, and `low` thresholds.
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## Status
|
|
165
|
+
|
|
166
|
+
| Component | Status |
|
|
167
|
+
|---|---|
|
|
168
|
+
| Rule engine — BaseRule, Finding, Severity | Complete |
|
|
169
|
+
| YAML parser with line number tracking | Complete |
|
|
170
|
+
| 13 security rules FS001-FS013 | Complete |
|
|
171
|
+
| MITRE ATT&CK + OWASP CICD Top 10 mapping | Complete |
|
|
172
|
+
| Platform-aware rule engine | Complete |
|
|
173
|
+
| GitHub Actions scanner — API and local file | Complete |
|
|
174
|
+
| GitLab CI scanner | Complete |
|
|
175
|
+
| Azure DevOps scanner | Complete |
|
|
176
|
+
| CLI — --repo, --file, --gitlab, --azure, --ai, --output, --fail-on | Complete |
|
|
177
|
+
| Rich terminal output with risk score | Complete |
|
|
178
|
+
| HTML report with filtering, PDF export | Complete |
|
|
179
|
+
| AI attack narratives with local caching | Complete |
|
|
180
|
+
| Line number tracking | Complete |
|
|
181
|
+
| Pipeline gate flag --fail-on | Complete |
|
|
182
|
+
| GitHub Codespace config | Complete |
|
|
183
|
+
| PyPI publish | Pending |
|
|
184
|
+
| Web app — FastAPI + frontend | Pending |
|
|
185
|
+
| Web app — Azure Container Apps deployment | Pending |
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Roadmap
|
|
190
|
+
|
|
191
|
+
**Phase 1 — Core CLI**
|
|
192
|
+
PyPI publish so anyone can `pip install flowsec`.
|
|
193
|
+
|
|
194
|
+
**Phase 2 — Web App**
|
|
195
|
+
FastAPI backend, frontend for repo input and file upload, deployed to Azure Container Apps via GitHub Actions. Anyone can scan a pipeline from a browser without installing anything.
|
|
196
|
+
|
|
197
|
+
**Phase 3 — Expansion**
|
|
198
|
+
Jenkins support, AWS CodePipeline, 20+ rule library, rate limiting.
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## About this project
|
|
203
|
+
|
|
204
|
+
Built as a security engineering portfolio project. The goal was to build an actual security tool — not run existing ones — and document every decision along the way.
|
|
205
|
+
|
|
206
|
+
**[Read the full documentation](docs/FULL_README.md)**
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
pipelineguard/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
pipelineguard/ai_narrative.py,sha256=WQHwsDP488vMnctyNf_NGdnJ9Qjrc6fOWUb66eGevw8,2411
|
|
3
|
+
pipelineguard/cli.py,sha256=YTlpkrpnzDjZF5AZ1LY5DR6i02QTKIzxzyZtp52Qiv0,5662
|
|
4
|
+
pipelineguard/parser.py,sha256=snrYk8W579UkBuIT2PLEO72NYhnBbT0QcfGheFaSC4E,808
|
|
5
|
+
pipelineguard/report.py,sha256=LvPtJ_XPmcxyCavn7FTKq7urld0CfI0JrYUEOQZCnAI,1224
|
|
6
|
+
pipelineguard/scanner.py,sha256=Ag_zF3jDIXxxxvU7B5FuwdQNiSL5la683btpq0ya_Qo,2863
|
|
7
|
+
pipelineguard/scoring.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
pipelineguard/rules/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
pipelineguard/rules/artifact_signing.py,sha256=JIknEweoCs-nAEnC033lfQYSxoMMFyWdD50Hei_DzdM,2385
|
|
10
|
+
pipelineguard/rules/base.py,sha256=7tprAb2MpjuWSNtQE7wgwKepn7Ff439FkDip5M7VFDk,684
|
|
11
|
+
pipelineguard/rules/dependency_pinning.py,sha256=8e3V_VxNTQW5U1eJTxv1RvQCnUolAug25y20Fb3TV7I,3043
|
|
12
|
+
pipelineguard/rules/excessive_permissions.py,sha256=0oDNBcMhACGQ2WFWbmJX_4TnxQEXV4N-UHIz8_9tc0A,1588
|
|
13
|
+
pipelineguard/rules/hardcoded_secrets.py,sha256=qH8AJ2tQw_Nx_ZlDPNBSZVQeJraBca5jaRqw_mG0j6E,5325
|
|
14
|
+
pipelineguard/rules/missing_branch_protection.py,sha256=NblY4Z4hzLVncsxiSkEpYezy6G9IWWltra26Nxsq0rI,622
|
|
15
|
+
pipelineguard/rules/missing_env_protection.py,sha256=n77Qd7sjXy3nYttzSs2kBYKNiwktMUuGUG2oPmnWYoo,1605
|
|
16
|
+
pipelineguard/rules/missing_oidc.py,sha256=-F24Lxnai8yvPj5BWZcuAB0ksf4XVEVc89qSLbk_LrQ,2183
|
|
17
|
+
pipelineguard/rules/missing_timeout.py,sha256=EEA4-cHWqk3Fd-5ULtRnQ85f0P6OaTAlhAI-BX8toXU,2873
|
|
18
|
+
pipelineguard/rules/pull_request_target.py,sha256=JCCYh8sEOYvOlqqLGqLMWdsOXUFU6cl2Kb0fpHe8J10,2497
|
|
19
|
+
pipelineguard/rules/secrets_in_run.py,sha256=X_n3RybHmi4eXpRoGlzXAWfpiwwOPNX6_CzY9qPm6QQ,2859
|
|
20
|
+
pipelineguard/rules/self_hosted_runner.py,sha256=6zteDVpbCyBJKkyypJo6o1AZEsfXNhQ3I7K4F7Qcu3s,3819
|
|
21
|
+
pipelineguard/rules/unpinned_actions.py,sha256=u11S4jlU__1vr2FldzLHGavDKUtnej9pvbNsVUQjOGY,1770
|
|
22
|
+
pipelineguard/rules/workflow_dispatch_injection.py,sha256=fbKFKzK3CmIGdgIblWmb36_v_LKZ45Vy-LB1mx4R1jg,2127
|
|
23
|
+
flowsec-0.1.0.dist-info/METADATA,sha256=OlrmMdQL2tCN-HJJgjxx10nbXIHkF0nt1z9tTdfvsTU,7553
|
|
24
|
+
flowsec-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
25
|
+
flowsec-0.1.0.dist-info/entry_points.txt,sha256=98BGjj8_rTlHPCtlS8fQyHNKPViKzvprQwDpKWRKEoM,51
|
|
26
|
+
flowsec-0.1.0.dist-info/RECORD,,
|
|
File without changes
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import hashlib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from anthropic import Anthropic
|
|
6
|
+
from dotenv import load_dotenv
|
|
7
|
+
from .rules.base import Finding
|
|
8
|
+
|
|
9
|
+
load_dotenv()
|
|
10
|
+
|
|
11
|
+
CACHE_FILE = Path.home() / ".flowsec_cache.json"
|
|
12
|
+
|
|
13
|
+
def _get_cache_key(finding: Finding) -> str:
|
|
14
|
+
content = f"{finding.rule_id}:{finding.description}"
|
|
15
|
+
return hashlib.md5(content.encode()).hexdigest()
|
|
16
|
+
|
|
17
|
+
def _load_cache() -> dict:
|
|
18
|
+
if CACHE_FILE.exists():
|
|
19
|
+
with open(CACHE_FILE) as f:
|
|
20
|
+
return json.load(f)
|
|
21
|
+
return {}
|
|
22
|
+
|
|
23
|
+
def _save_cache(cache: dict) -> None:
|
|
24
|
+
with open(CACHE_FILE, "w") as f:
|
|
25
|
+
json.dump(cache, f, indent=2)
|
|
26
|
+
|
|
27
|
+
def generate_narrative(finding: Finding) -> str:
|
|
28
|
+
cache = _load_cache()
|
|
29
|
+
key = _get_cache_key(finding)
|
|
30
|
+
|
|
31
|
+
if key in cache:
|
|
32
|
+
return cache[key]
|
|
33
|
+
|
|
34
|
+
client = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
|
|
35
|
+
|
|
36
|
+
prompt = f"""
|
|
37
|
+
|
|
38
|
+
You are an offensive security engineer specializing in CI/CD pipeline attacks and supply chain security.
|
|
39
|
+
You are writing a threat intelligence brief for a security team that has just received a finding from an automated pipeline scanner.
|
|
40
|
+
Your job is to write a concise, specific attack narrative for this finding.
|
|
41
|
+
Do not write generic advice.
|
|
42
|
+
|
|
43
|
+
Write exactly how a real attacker would exploit this specific misconfiguration — what they would do first, what access they would gain, and what the realistic impact is on this organization.
|
|
44
|
+
|
|
45
|
+
Write exactly this structure, no markdown, no asterisks, no bold syntax:
|
|
46
|
+
|
|
47
|
+
**Attack Vector:** [1-2 sentence — how they get in]
|
|
48
|
+
**What They Gain:** [1-2 sentence — what access/capability]
|
|
49
|
+
**Blast Radius:** [1-2 sentence — realistic worst case impact]
|
|
50
|
+
**Ways to Fix:** [1-2 sentence — Explain ways to fix this]
|
|
51
|
+
|
|
52
|
+
Rule: {finding.rule_id} — {finding.title}
|
|
53
|
+
Description: {finding.description}
|
|
54
|
+
MITRE Technique: {finding.mitre_technique}
|
|
55
|
+
|
|
56
|
+
Write only the attack narrative, no preamble.
|
|
57
|
+
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
message = client.messages.create(
|
|
61
|
+
model="claude-haiku-4-5-20251001",
|
|
62
|
+
max_tokens=200,
|
|
63
|
+
messages=[
|
|
64
|
+
{"role": "user", "content": prompt}
|
|
65
|
+
]
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
response_text = message.content[0].text
|
|
69
|
+
|
|
70
|
+
cache[key] = response_text
|
|
71
|
+
_save_cache(cache)
|
|
72
|
+
|
|
73
|
+
return response_text
|
pipelineguard/cli.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
from rich import box
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.panel import Panel
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
from .scanner import scan_file, scan_repo, scan_gitlab_file, scan_azure_file
|
|
8
|
+
|
|
9
|
+
from .rules.base import Finding
|
|
10
|
+
from .scanner import scan_file, scan_repo, scan_gitlab_file
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
SEVERITY_COLORS = {
|
|
15
|
+
"critical": "bold red",
|
|
16
|
+
"high": "bold orange3",
|
|
17
|
+
"medium": "bold yellow",
|
|
18
|
+
"low": "bold blue",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def display_findings(findings: list[Finding]) -> None:
|
|
23
|
+
console.print()
|
|
24
|
+
console.print(Panel("[bold white]FlowSec Security Report[/bold white]", style="blue"))
|
|
25
|
+
console.print()
|
|
26
|
+
|
|
27
|
+
table = Table(box=box.ROUNDED, show_header=True, header_style="bold white")
|
|
28
|
+
table.add_column("Severity", width=8)
|
|
29
|
+
table.add_column("Rule ID", width=8)
|
|
30
|
+
table.add_column("Title", width=50)
|
|
31
|
+
table.add_column("OWASP", width=12)
|
|
32
|
+
table.add_column("File", width=40)
|
|
33
|
+
|
|
34
|
+
for f in findings:
|
|
35
|
+
color = SEVERITY_COLORS.get(f.severity.value, "white")
|
|
36
|
+
table.add_row(
|
|
37
|
+
f"[{color}]{f.severity.value.upper()}[/{color}]",
|
|
38
|
+
f.rule_id,
|
|
39
|
+
f.title,
|
|
40
|
+
f.owasp_category or "",
|
|
41
|
+
f.file_path,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
console.print(table)
|
|
45
|
+
console.print()
|
|
46
|
+
|
|
47
|
+
critical = sum(1 for f in findings if f.severity.value == "critical")
|
|
48
|
+
high = sum(1 for f in findings if f.severity.value == "high")
|
|
49
|
+
medium = sum(1 for f in findings if f.severity.value == "medium")
|
|
50
|
+
low = sum(1 for f in findings if f.severity.value == "low")
|
|
51
|
+
score = (critical * 10) + (high * 5) + (medium * 3) + (low * 1)
|
|
52
|
+
|
|
53
|
+
summary = (
|
|
54
|
+
f"[bold red]Critical: {critical}[/bold red] "
|
|
55
|
+
f"[bold orange3]High: {high}[/bold orange3] "
|
|
56
|
+
f"[bold yellow]Medium: {medium}[/bold yellow] "
|
|
57
|
+
f"[bold blue]Low: {low}[/bold blue]\n\n"
|
|
58
|
+
f"[bold white]Total Findings: {len(findings)}[/bold white] | "
|
|
59
|
+
f"[bold white]Risk Score: {score}[/bold white]"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
console.print(Panel(summary, title="[bold white]Summary[/bold white]", style="blue"))
|
|
63
|
+
console.print()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def main() -> None:
|
|
67
|
+
parser = argparse.ArgumentParser(description="FlowSec - CI/CD pipeline security analyzer")
|
|
68
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
69
|
+
|
|
70
|
+
scan_parser = subparsers.add_parser("scan", help="Scan a pipeline")
|
|
71
|
+
scan_parser.add_argument("--repo", help="GitHub repo name e.g. owner/repo")
|
|
72
|
+
scan_parser.add_argument("--file", help="Path to a local GitHub Actions workflow file")
|
|
73
|
+
scan_parser.add_argument("--gitlab", help="Path to a local GitLab CI file")
|
|
74
|
+
scan_parser.add_argument("--azure", help="Path to a local Azure DevOps pipeline file")
|
|
75
|
+
scan_parser.add_argument("--output", help="Save HTML report to this path")
|
|
76
|
+
scan_parser.add_argument("--ai", action="store_true", help="Generate AI attack narratives per finding")
|
|
77
|
+
scan_parser.add_argument(
|
|
78
|
+
"--fail-on",
|
|
79
|
+
choices=["critical", "high", "medium", "low"],
|
|
80
|
+
help="Exit with code 1 if findings at or above this severity are found"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
args = parser.parse_args()
|
|
84
|
+
|
|
85
|
+
if args.command == "scan":
|
|
86
|
+
findings: list[Finding] = []
|
|
87
|
+
|
|
88
|
+
if args.repo:
|
|
89
|
+
console.print(f"\n[bold blue]Scanning[/bold blue] [white]{args.repo}[/white]...\n")
|
|
90
|
+
findings = scan_repo(args.repo)
|
|
91
|
+
elif args.file:
|
|
92
|
+
console.print(f"\n[bold blue]Scanning[/bold blue] [white]{args.file}[/white]...\n")
|
|
93
|
+
findings = scan_file(args.file)
|
|
94
|
+
elif args.gitlab:
|
|
95
|
+
console.print(f"\n[bold blue]Scanning GitLab CI[/bold blue] [white]{args.gitlab}[/white]...\n")
|
|
96
|
+
findings = scan_gitlab_file(args.gitlab)
|
|
97
|
+
elif args.azure:
|
|
98
|
+
console.print(f"\n[bold blue]Scanning Azure DevOps[/bold blue] [white]{args.azure}[/white]...\n")
|
|
99
|
+
from .scanner import scan_azure_file
|
|
100
|
+
findings = scan_azure_file(args.azure)
|
|
101
|
+
else:
|
|
102
|
+
console.print("[bold red]Error:[/bold red] provide --repo, --file, or --gitlab")
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
if not findings:
|
|
106
|
+
console.print(Panel("[bold green]No findings. Pipeline looks clean.[/bold green]", style="green"))
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
if args.ai:
|
|
110
|
+
from .ai_narrative import generate_narrative
|
|
111
|
+
console.print("\n[bold blue]Generating AI attack narratives...[/bold blue]\n")
|
|
112
|
+
for f in findings:
|
|
113
|
+
f.narrative = generate_narrative(f)
|
|
114
|
+
|
|
115
|
+
display_findings(findings)
|
|
116
|
+
|
|
117
|
+
if args.ai:
|
|
118
|
+
console.print()
|
|
119
|
+
console.print(Panel("[bold white]AI Attack Narratives[/bold white]", style="blue"))
|
|
120
|
+
console.print()
|
|
121
|
+
for f in findings:
|
|
122
|
+
if f.narrative:
|
|
123
|
+
console.print(f"[bold]{f.rule_id}[/bold] — {f.title}")
|
|
124
|
+
console.print(f"[dim]{f.narrative}[/dim]")
|
|
125
|
+
console.print()
|
|
126
|
+
|
|
127
|
+
if args.output:
|
|
128
|
+
from .report import generateReport
|
|
129
|
+
generateReport(findings, args.repo or args.file or args.gitlab or args.azure or "local scan", args.output)
|
|
130
|
+
console.print(f"\n[bold green]Report saved to {args.output}[/bold green]")
|
|
131
|
+
|
|
132
|
+
if args.fail_on:
|
|
133
|
+
SEVERITY_ORDER = ["low", "medium", "high", "critical"]
|
|
134
|
+
threshold = SEVERITY_ORDER.index(args.fail_on.lower())
|
|
135
|
+
failing = [f for f in findings if SEVERITY_ORDER.index(f.severity.value) >= threshold]
|
|
136
|
+
if failing:
|
|
137
|
+
console.print(f"\n[bold red]Pipeline failed — {len(failing)} finding(s) at or above {args.fail_on.upper()} severity.[/bold red]")
|
|
138
|
+
sys.exit(1)
|
|
139
|
+
|
|
140
|
+
else:
|
|
141
|
+
parser.print_help()
|
pipelineguard/parser.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import yaml
|
|
2
|
+
from yaml.resolver import BaseResolver
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class LineLoader(yaml.SafeLoader):
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def construct_mapping(loader, node):
|
|
10
|
+
loader.flatten_mapping(node)
|
|
11
|
+
mapping = {}
|
|
12
|
+
for key_node, value_node in node.value:
|
|
13
|
+
key = loader.construct_object(key_node)
|
|
14
|
+
value = loader.construct_object(value_node)
|
|
15
|
+
mapping[key] = value
|
|
16
|
+
if isinstance(value, str):
|
|
17
|
+
mapping[f"__line_{key}__"] = key_node.start_mark.line + 1
|
|
18
|
+
return mapping
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
LineLoader.add_constructor(
|
|
22
|
+
BaseResolver.DEFAULT_MAPPING_TAG,
|
|
23
|
+
construct_mapping,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_pipeline(file_path: str) -> dict:
|
|
28
|
+
with open(file_path, "r") as f:
|
|
29
|
+
return yaml.load(f, Loader=LineLoader)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_pipeline_with_lines(content: str) -> dict:
|
|
33
|
+
return yaml.load(content, Loader=LineLoader)
|
pipelineguard/report.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from jinja2 import Environment, FileSystemLoader
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from .rules.base import Finding
|
|
5
|
+
|
|
6
|
+
def generateReport(findings: list[Finding], repo_name: str, output_path: str)-> None:
|
|
7
|
+
|
|
8
|
+
env = Environment(loader=FileSystemLoader(
|
|
9
|
+
Path(__file__).parent / "templates"
|
|
10
|
+
))
|
|
11
|
+
template = env.get_template("report.html")
|
|
12
|
+
|
|
13
|
+
critical = sum(1 for f in findings if f.severity.value == "critical")
|
|
14
|
+
high = sum(1 for f in findings if f.severity.value == "high")
|
|
15
|
+
medium = sum(1 for f in findings if f.severity.value == "medium")
|
|
16
|
+
low = sum(1 for f in findings if f.severity.value == "low")
|
|
17
|
+
risk_score = (critical * 10) + (high * 5) + (medium * 3) + (low * 1)
|
|
18
|
+
|
|
19
|
+
data = {
|
|
20
|
+
"repo_name": repo_name,
|
|
21
|
+
"scan_date": datetime.now().strftime("%B %d, %Y %I:%M %p"),
|
|
22
|
+
"file_count": len(set(f.file_path for f in findings)),
|
|
23
|
+
"total": len(findings),
|
|
24
|
+
"critical": critical,
|
|
25
|
+
"high": high,
|
|
26
|
+
"medium": medium,
|
|
27
|
+
"low": low,
|
|
28
|
+
"risk_score": risk_score,
|
|
29
|
+
"findings": findings,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
rendered_html = template.render(data)
|
|
33
|
+
|
|
34
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
35
|
+
f.write(rendered_html)
|
|
File without changes
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from .base import BaseRule, Finding, Severity
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ArtifactSigningRule(BaseRule):
|
|
6
|
+
rule_id = "FS008"
|
|
7
|
+
title = "Missing Artifact Signing — No Tamper Protection"
|
|
8
|
+
severity = Severity.MEDIUM
|
|
9
|
+
|
|
10
|
+
# Actions that publish artifacts or packages without signing
|
|
11
|
+
PUBLISHING_ACTIONS = [
|
|
12
|
+
"actions/upload-artifact",
|
|
13
|
+
"pypa/gh-action-pypi-publish",
|
|
14
|
+
"docker/build-push-action",
|
|
15
|
+
"softprops/action-gh-release",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
# Actions that indicate signing is present
|
|
19
|
+
SIGNING_ACTIONS = [
|
|
20
|
+
"sigstore/cosign-installer",
|
|
21
|
+
"anchore/sbom-action",
|
|
22
|
+
"slsa-framework/slsa-github-generator",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
def _extract_uses(self, config: dict[str, Any]) -> list[str]:
|
|
26
|
+
uses = []
|
|
27
|
+
jobs = config.get("jobs", {})
|
|
28
|
+
for job in jobs.values():
|
|
29
|
+
for step in job.get("steps", []):
|
|
30
|
+
if "uses" in step:
|
|
31
|
+
uses.append(step["uses"])
|
|
32
|
+
return uses
|
|
33
|
+
|
|
34
|
+
def check(self, config: dict[str, Any], file_path: str, platform: str = "github") -> list[Finding]:
|
|
35
|
+
if platform != "github":
|
|
36
|
+
return []
|
|
37
|
+
findings = []
|
|
38
|
+
uses = self._extract_uses(config)
|
|
39
|
+
|
|
40
|
+
publishes_artifact = any(
|
|
41
|
+
any(action in step for action in self.PUBLISHING_ACTIONS)
|
|
42
|
+
for step in uses
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
has_signing = any(
|
|
46
|
+
any(action in step for action in self.SIGNING_ACTIONS)
|
|
47
|
+
for step in uses
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if publishes_artifact and not has_signing:
|
|
51
|
+
findings.append(Finding(
|
|
52
|
+
rule_id=self.rule_id,
|
|
53
|
+
title=self.title,
|
|
54
|
+
severity=self.severity,
|
|
55
|
+
description="This pipeline publishes artifacts or packages without cryptographic signing. Without signed artifacts, consumers have no way to verify that what they downloaded was actually built by your pipeline and has not been tampered with in transit or storage.",
|
|
56
|
+
remediation="Add artifact signing using Sigstore/cosign or the SLSA framework. For Docker images use cosign to sign after push. For PyPI packages use sigstore. For GitHub releases attach SLSA provenance using slsa-framework/slsa-github-generator.",
|
|
57
|
+
mitre_technique="T1553",
|
|
58
|
+
file_path=file_path,
|
|
59
|
+
owasp_category="CICD-SEC-8",
|
|
60
|
+
|
|
61
|
+
))
|
|
62
|
+
|
|
63
|
+
return findings
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Severity(Enum):
|
|
8
|
+
CRITICAL = "critical"
|
|
9
|
+
HIGH = "high"
|
|
10
|
+
MEDIUM = "medium"
|
|
11
|
+
LOW = "low"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Finding:
|
|
16
|
+
rule_id: str
|
|
17
|
+
title: str
|
|
18
|
+
severity: Severity
|
|
19
|
+
description: str
|
|
20
|
+
remediation: str
|
|
21
|
+
mitre_technique: str
|
|
22
|
+
file_path: str
|
|
23
|
+
line_number: int = 0
|
|
24
|
+
narrative: str = ""
|
|
25
|
+
owasp_category: str = ""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BaseRule(ABC):
|
|
31
|
+
rule_id: str
|
|
32
|
+
title: str
|
|
33
|
+
severity: Severity
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def check(self, config: dict[str, Any], file_path: str, platform: str = "github") -> list[Finding]:
|
|
37
|
+
...
|