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.
@@ -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
+ [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ flowsec = pipelineguard.cli:main
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()
@@ -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)
@@ -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
+ ...