1+ import re
2+ import sys
3+ from pathlib import Path
4+ from typing import List , Tuple
5+
6+
7+ class GitHubActionChecker :
8+ def __init__ (self ):
9+ # Pattern for actions with SHA-1 hashes (pinned)
10+ self .pinned_pattern = re .compile (r"uses:\s+([^@\s]+)@([a-f0-9]{40})" )
11+
12+ # Pattern for actions with version tags (unpinned)
13+ self .unpinned_pattern = re .compile (
14+ r"uses:\s+([^@\s]+)@(v\d+(?:\.\d+)*(?:-[a-zA-Z0-9]+(?:\.\d+)*)?)"
15+ )
16+
17+ # Pattern for all uses statements
18+ self .all_uses_pattern = re .compile (r"uses:\s+([^@\s]+)@([^\s\n]+)" )
19+
20+ def get_line_numbers (
21+ self , content : str , pattern : re .Pattern
22+ ) -> List [Tuple [str , int ]]:
23+ """Find matches with their line numbers."""
24+ matches = []
25+ for i , line in enumerate (content .splitlines (), 1 ):
26+ for match in pattern .finditer (line ):
27+ matches .append ((match .group (0 ), i ))
28+ return matches
29+
30+ def check_file (self , file_path : str ) -> bool :
31+ """Check a single file for unpinned dependencies."""
32+ try :
33+ content = Path (file_path ).read_text ()
34+ except Exception as e :
35+ print (f"\033 [91mError reading file { file_path } : { e } \033 [0m" )
36+ return False
37+
38+ # Get matches with line numbers
39+ pinned_matches = self .get_line_numbers (content , self .pinned_pattern )
40+ unpinned_matches = self .get_line_numbers (content , self .unpinned_pattern )
41+ all_matches = self .get_line_numbers (content , self .all_uses_pattern )
42+
43+ print (f"\n \033 [1m[=] Checking file: { file_path } \033 [0m" )
44+
45+ # Print pinned dependencies
46+ if pinned_matches :
47+ print ("\033 [92m[+] Pinned:\033 [0m" )
48+ for match , line_num in pinned_matches :
49+ print (f" |- { match } \033 [90m({ file_path } :{ line_num } )\033 [0m" )
50+
51+ # Track all found actions for validation
52+ found_actions = set ()
53+ for match , _ in pinned_matches + unpinned_matches :
54+ action_name = self .pinned_pattern .match (
55+ match
56+ ) or self .unpinned_pattern .match (match )
57+ if action_name :
58+ found_actions .add (action_name .group (1 ))
59+
60+ has_errors = False
61+
62+ # Check for unpinned dependencies
63+ if unpinned_matches :
64+ has_errors = True
65+ print ("\033 [93m[!] Unpinned (using version tags):\033 [0m" )
66+ for match , line_num in unpinned_matches :
67+ print (f" |- { match } \033 [90m({ file_path } :{ line_num } )\033 [0m" )
68+
69+ # Check for completely unpinned dependencies (no SHA or version)
70+ unpinned_without_hash = [
71+ (match , line_num )
72+ for match , line_num in all_matches
73+ if not any (match in pinned [0 ] for pinned in pinned_matches )
74+ and not any (match in unpinned [0 ] for unpinned in unpinned_matches )
75+ ]
76+
77+ if unpinned_without_hash :
78+ has_errors = True
79+ print ("\033 [91m[!] Completely unpinned (no SHA or version):\033 [0m" )
80+ for match , line_num in unpinned_without_hash :
81+ print (
82+ f" |- { match } \033 [90m({ self .format_terminal_link (file_path , line_num )} )\033 [0m"
83+ )
84+
85+ # Print summary
86+ total_actions = (
87+ len (pinned_matches ) + len (unpinned_matches ) + len (unpinned_without_hash )
88+ )
89+ if total_actions == 0 :
90+ print ("\033 [93m[!] No GitHub Actions found in this file\033 [0m" )
91+ else :
92+ print ("\n \033 [1mSummary:\033 [0m" )
93+ print (f"Total actions: { total_actions } " )
94+ print (f"Pinned: { len (pinned_matches )} " )
95+ print (f"Unpinned with version: { len (unpinned_matches )} " )
96+ print (f"Completely unpinned: { len (unpinned_without_hash )} " )
97+
98+ return not has_errors
99+
100+
101+ def main ():
102+ checker = GitHubActionChecker ()
103+ files_to_check = sys .argv [1 :]
104+
105+ if not files_to_check :
106+ print ("\033 [91mError: No files provided to check\033 [0m" )
107+ print ("Usage: python script.py <file1> <file2> ..." )
108+ sys .exit (1 )
109+
110+ results = {file : checker .check_file (file ) for file in files_to_check }
111+
112+ # Print final summary
113+ print ("\n \033 [1mFinal Results:\033 [0m" )
114+ for file , passed in results .items ():
115+ status = "\033 [92m✓ Passed\033 [0m" if passed else "\033 [91m✗ Failed\033 [0m"
116+ print (f"{ status } { file } " )
117+
118+ if not all (results .values ()):
119+ sys .exit (1 )
120+
121+
122+ if __name__ == "__main__" :
123+ main ()
0 commit comments