push-guard 0.2.0__tar.gz → 0.2.2__tar.gz
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.
- {push_guard-0.2.0 → push_guard-0.2.2}/PKG-INFO +9 -1
- {push_guard-0.2.0 → push_guard-0.2.2}/README.md +8 -0
- {push_guard-0.2.0 → push_guard-0.2.2}/pyproject.toml +1 -1
- {push_guard-0.2.0 → push_guard-0.2.2}/src/push_guard/guard.py +382 -0
- {push_guard-0.2.0 → push_guard-0.2.2}/src/push_guard.egg-info/PKG-INFO +9 -1
- {push_guard-0.2.0 → push_guard-0.2.2}/tests/test_guard.py +257 -0
- {push_guard-0.2.0 → push_guard-0.2.2}/LICENSE +0 -0
- {push_guard-0.2.0 → push_guard-0.2.2}/setup.cfg +0 -0
- {push_guard-0.2.0 → push_guard-0.2.2}/src/push_guard/__init__.py +0 -0
- {push_guard-0.2.0 → push_guard-0.2.2}/src/push_guard/__main__.py +0 -0
- {push_guard-0.2.0 → push_guard-0.2.2}/src/push_guard.egg-info/SOURCES.txt +0 -0
- {push_guard-0.2.0 → push_guard-0.2.2}/src/push_guard.egg-info/dependency_links.txt +0 -0
- {push_guard-0.2.0 → push_guard-0.2.2}/src/push_guard.egg-info/entry_points.txt +0 -0
- {push_guard-0.2.0 → push_guard-0.2.2}/src/push_guard.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: push-guard
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Local pre-push guard for likely secret leaks and private file paths.
|
|
5
5
|
Author: Dragon Lady
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -60,6 +60,14 @@ matched values.
|
|
|
60
60
|
- private key block markers
|
|
61
61
|
- generic long `api_key`, `token`, `secret`, or `password` assignments,
|
|
62
62
|
including underscore/dash-delimited names such as `AWS_SECRET_ACCESS_KEY`
|
|
63
|
+
- Astro config loader/C2 patterns in `astro.config.*` and related
|
|
64
|
+
`.gitignore` helper-artifact hiding, based on reported config-as-code
|
|
65
|
+
supply-chain abuse
|
|
66
|
+
- OpenClaw dependency versions before `2026.4.23` and risky OpenClaw
|
|
67
|
+
open-DM/wildcard/unsandboxed configuration lines
|
|
68
|
+
- npm v12 readiness regressions in pushed npm metadata, including old npm pins,
|
|
69
|
+
Git or remote tarball dependency sources, and broad repo `.npmrc` opt-ins for
|
|
70
|
+
install-time execution or dependency fetching
|
|
63
71
|
|
|
64
72
|
All evidence is redacted as `<redacted>`.
|
|
65
73
|
|
|
@@ -39,6 +39,14 @@ matched values.
|
|
|
39
39
|
- private key block markers
|
|
40
40
|
- generic long `api_key`, `token`, `secret`, or `password` assignments,
|
|
41
41
|
including underscore/dash-delimited names such as `AWS_SECRET_ACCESS_KEY`
|
|
42
|
+
- Astro config loader/C2 patterns in `astro.config.*` and related
|
|
43
|
+
`.gitignore` helper-artifact hiding, based on reported config-as-code
|
|
44
|
+
supply-chain abuse
|
|
45
|
+
- OpenClaw dependency versions before `2026.4.23` and risky OpenClaw
|
|
46
|
+
open-DM/wildcard/unsandboxed configuration lines
|
|
47
|
+
- npm v12 readiness regressions in pushed npm metadata, including old npm pins,
|
|
48
|
+
Git or remote tarball dependency sources, and broad repo `.npmrc` opt-ins for
|
|
49
|
+
install-time execution or dependency fetching
|
|
42
50
|
|
|
43
51
|
All evidence is redacted as `<redacted>`.
|
|
44
52
|
|
|
@@ -6,6 +6,7 @@ import os
|
|
|
6
6
|
import re
|
|
7
7
|
import subprocess
|
|
8
8
|
import sys
|
|
9
|
+
import urllib.parse
|
|
9
10
|
from dataclasses import dataclass
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
|
|
@@ -141,6 +142,99 @@ HADES_PYPI_PATTERNS = [
|
|
|
141
142
|
),
|
|
142
143
|
]
|
|
143
144
|
|
|
145
|
+
OTTERCOOKIE_NPM_PATTERNS = [
|
|
146
|
+
(
|
|
147
|
+
"workflow.ottercookie_vercel_c2",
|
|
148
|
+
re.compile(
|
|
149
|
+
r"cloudflare(?:insights|firewall|security)(?:\[\.\]|\.)vercel(?:\[\.\]|\.)app", # push-guard: ignore
|
|
150
|
+
re.I,
|
|
151
|
+
), # push-guard: ignore
|
|
152
|
+
"OtterCookie npm Vercel-hosted C2 domain",
|
|
153
|
+
),
|
|
154
|
+
(
|
|
155
|
+
"workflow.ottercookie_npm_package",
|
|
156
|
+
re.compile(
|
|
157
|
+
r"\b(?:bjs-lint-builders|bjs-lint-builder|bjs-biginteger|" # push-guard: ignore
|
|
158
|
+
r"hjs-lint-builders|sjs-builders|sjs-builder|npm-doc-builder)\b", # push-guard: ignore
|
|
159
|
+
re.I,
|
|
160
|
+
), # push-guard: ignore
|
|
161
|
+
"Panther OtterCookie npm package indicator",
|
|
162
|
+
),
|
|
163
|
+
(
|
|
164
|
+
"workflow.ottercookie_ssh_backdoor_shape",
|
|
165
|
+
re.compile(r"authorized_keys|ufw\s+allow\s+22/tcp|/api/ssh-key", re.I), # push-guard: ignore
|
|
166
|
+
"OtterCookie SSH key or firewall backdoor behavior",
|
|
167
|
+
),
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
ASTRO_CONFIG_C2_PATTERNS = [
|
|
171
|
+
(
|
|
172
|
+
"workflow.astro_config_create_require",
|
|
173
|
+
re.compile(r"\bcreateRequire\s*\(", re.I),
|
|
174
|
+
"Astro config reconstructs CommonJS require",
|
|
175
|
+
),
|
|
176
|
+
(
|
|
177
|
+
"workflow.astro_config_eval_sink",
|
|
178
|
+
re.compile(r"\b(?:eval|Function)\s*\(", re.I),
|
|
179
|
+
"Astro config contains JavaScript eval/function execution sink",
|
|
180
|
+
),
|
|
181
|
+
(
|
|
182
|
+
"workflow.astro_config_network_loader",
|
|
183
|
+
re.compile(
|
|
184
|
+
r"\brequire\s*\(\s*['\"](?:node:)?https?['\"]\s*\)|"
|
|
185
|
+
r"\bfrom\s+['\"](?:node:)?https?['\"]|"
|
|
186
|
+
r"\bhttps?\s*\.\s*(?:request|get)\s*\(|"
|
|
187
|
+
r"\bfetch\s*\(",
|
|
188
|
+
re.I,
|
|
189
|
+
),
|
|
190
|
+
"Astro config contains network loader behavior",
|
|
191
|
+
),
|
|
192
|
+
(
|
|
193
|
+
"workflow.astro_config_blockchain_c2",
|
|
194
|
+
re.compile(
|
|
195
|
+
r"trongrid|aptoslabs|bsc-dataseed|publicnode|eth_getTransactionByHash|"
|
|
196
|
+
r"Sec-V|TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP", # push-guard: ignore
|
|
197
|
+
re.I,
|
|
198
|
+
),
|
|
199
|
+
"Astro config contains blockchain/C2 relay marker",
|
|
200
|
+
),
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
ASTRO_GITIGNORE_HIDE_FILES = re.compile(
|
|
204
|
+
r"\b(?:branch_structure\.json|temp_auto_push\.bat|temp_interactive_push\.bat)\b",
|
|
205
|
+
re.I,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
OPENCLAW_FIXED_VERSION = "2026.4.23"
|
|
209
|
+
OPENCLAW_VERSION_RE = re.compile(
|
|
210
|
+
r"\bopenclaw\b[^\n\r]{0,80}\b([0-9]{4}\.[0-9]{1,2}\.[0-9]{1,2})\b",
|
|
211
|
+
re.I,
|
|
212
|
+
)
|
|
213
|
+
OPENCLAW_OPEN_DM_POLICY_RE = re.compile(r"\bdmPolicy[\"']?\s*[:=]\s*[\"']open[\"']", re.I)
|
|
214
|
+
OPENCLAW_WILDCARD_ALLOW_RE = re.compile(
|
|
215
|
+
r"\ballowFrom[\"']?\s*[:=][^\n\r]{0,160}[\"']\*[\"']",
|
|
216
|
+
re.I,
|
|
217
|
+
)
|
|
218
|
+
OPENCLAW_DISABLED_SANDBOX_RE = re.compile(
|
|
219
|
+
r"(?:agents\.defaults\.sandbox\.mode|sandbox[^\n\r]{0,80}\bmode)"
|
|
220
|
+
r"[\"']?\s*[:=]\s*[\"'](?:none|off|host|main|disabled)[\"']",
|
|
221
|
+
re.I,
|
|
222
|
+
)
|
|
223
|
+
NPM_V12_PREPARE_MIN_VERSION = "11.16.0"
|
|
224
|
+
NPM_VERSION_RE = re.compile(r"\bnpm\b[^\n\r0-9]{0,12}([0-9]+\.[0-9]+\.[0-9]+)\b", re.I)
|
|
225
|
+
NPM_REMOTE_TARBALL_RE = re.compile(
|
|
226
|
+
r"[\"']https?://[^\"'\s]+(?:\.tgz|\.tar\.gz)(?:[?#][^\"'\s]*)?[\"']",
|
|
227
|
+
re.I,
|
|
228
|
+
)
|
|
229
|
+
NPM_GIT_DEPENDENCY_RE = re.compile(
|
|
230
|
+
r"[\"'](?:git\+https?|git|github|gitlab|bitbucket):[^\"']+[\"']|github\.com[:/]",
|
|
231
|
+
re.I,
|
|
232
|
+
)
|
|
233
|
+
NPM_BROAD_ALLOW_RE = re.compile(
|
|
234
|
+
r"^\s*(allow-git|allow-remote|allow-scripts)\s*=\s*(?:true|all|\*)\s*$",
|
|
235
|
+
re.I,
|
|
236
|
+
)
|
|
237
|
+
|
|
144
238
|
|
|
145
239
|
# ---------------------------------------------------------------------------
|
|
146
240
|
# Private-path rules.
|
|
@@ -488,6 +582,11 @@ def _scan_line(line: str, path: str, line_number: int) -> list[SecretFinding]:
|
|
|
488
582
|
)
|
|
489
583
|
)
|
|
490
584
|
|
|
585
|
+
if not findings:
|
|
586
|
+
findings.extend(
|
|
587
|
+
_scan_normalized_high_confidence_token_shapes(line, path, line_number)
|
|
588
|
+
)
|
|
589
|
+
|
|
491
590
|
# Only consider the lower-confidence generic-assignment rule when no
|
|
492
591
|
# specific token already matched this line. This avoids double-reporting a
|
|
493
592
|
# single leak (e.g. OPENAI_API_KEY=sk-...) while still catching secrets that
|
|
@@ -508,10 +607,62 @@ def _scan_line(line: str, path: str, line_number: int) -> list[SecretFinding]:
|
|
|
508
607
|
|
|
509
608
|
findings.extend(_scan_line_for_workflow_compromise(line, path, line_number))
|
|
510
609
|
findings.extend(_scan_line_for_hades_pypi(line, path, line_number))
|
|
610
|
+
findings.extend(_scan_line_for_ottercookie_npm(line, path, line_number))
|
|
611
|
+
findings.extend(_scan_line_for_astro_config_c2(line, path, line_number))
|
|
612
|
+
findings.extend(_scan_line_for_openclaw_agent_exposure(line, path, line_number))
|
|
613
|
+
findings.extend(_scan_line_for_npm_v12_readiness(line, path, line_number))
|
|
511
614
|
|
|
512
615
|
return findings
|
|
513
616
|
|
|
514
617
|
|
|
618
|
+
def _scan_normalized_high_confidence_token_shapes(
|
|
619
|
+
line: str, path: str, line_number: int
|
|
620
|
+
) -> list[SecretFinding]:
|
|
621
|
+
findings: list[SecretFinding] = []
|
|
622
|
+
seen_rules: set[str] = set()
|
|
623
|
+
|
|
624
|
+
for variant in _normalized_secret_shape_variants(line):
|
|
625
|
+
for rule_id, pattern, reason, high_confidence in SECRET_PATTERNS:
|
|
626
|
+
if not high_confidence or rule_id in seen_rules:
|
|
627
|
+
continue
|
|
628
|
+
if pattern.search(variant):
|
|
629
|
+
seen_rules.add(rule_id)
|
|
630
|
+
findings.append(
|
|
631
|
+
SecretFinding(
|
|
632
|
+
rule_id=rule_id,
|
|
633
|
+
path=path,
|
|
634
|
+
line=line_number,
|
|
635
|
+
reason=f"{reason} in encoded or split text",
|
|
636
|
+
evidence="<redacted>",
|
|
637
|
+
)
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
return findings
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _normalized_secret_shape_variants(line: str) -> list[str]:
|
|
644
|
+
variants: list[str] = []
|
|
645
|
+
|
|
646
|
+
decoded = urllib.parse.unquote(line)
|
|
647
|
+
if decoded != line:
|
|
648
|
+
variants.append(decoded)
|
|
649
|
+
|
|
650
|
+
compact = re.sub(r"[\"'`]", "", line)
|
|
651
|
+
compact = re.sub(r"\s*\+\s*", "", compact)
|
|
652
|
+
compact = re.sub(r"\s+", "", compact)
|
|
653
|
+
if compact != line:
|
|
654
|
+
variants.append(compact)
|
|
655
|
+
|
|
656
|
+
if decoded != line:
|
|
657
|
+
decoded_compact = re.sub(r"[\"'`]", "", decoded)
|
|
658
|
+
decoded_compact = re.sub(r"\s*\+\s*", "", decoded_compact)
|
|
659
|
+
decoded_compact = re.sub(r"\s+", "", decoded_compact)
|
|
660
|
+
if decoded_compact != decoded and decoded_compact not in variants:
|
|
661
|
+
variants.append(decoded_compact)
|
|
662
|
+
|
|
663
|
+
return variants
|
|
664
|
+
|
|
665
|
+
|
|
515
666
|
def _scan_line_for_workflow_compromise(
|
|
516
667
|
line: str, path: str, line_number: int
|
|
517
668
|
) -> list[SecretFinding]:
|
|
@@ -599,6 +750,237 @@ def _scan_line_for_hades_pypi(
|
|
|
599
750
|
return findings
|
|
600
751
|
|
|
601
752
|
|
|
753
|
+
def _scan_line_for_ottercookie_npm(
|
|
754
|
+
line: str, path: str, line_number: int
|
|
755
|
+
) -> list[SecretFinding]:
|
|
756
|
+
normalized_path = path.replace("\\", "/")
|
|
757
|
+
lowered_path = normalized_path.lower()
|
|
758
|
+
if lowered_path.endswith((".md", ".mdx", ".txt", ".rst")):
|
|
759
|
+
return []
|
|
760
|
+
if not _is_workflow_or_script_path(normalized_path):
|
|
761
|
+
return []
|
|
762
|
+
|
|
763
|
+
findings: list[SecretFinding] = []
|
|
764
|
+
for rule_id, pattern, reason in OTTERCOOKIE_NPM_PATTERNS:
|
|
765
|
+
if pattern.search(line):
|
|
766
|
+
findings.append(
|
|
767
|
+
SecretFinding(
|
|
768
|
+
rule_id=rule_id,
|
|
769
|
+
path=path,
|
|
770
|
+
line=line_number,
|
|
771
|
+
reason=reason,
|
|
772
|
+
evidence="<redacted>",
|
|
773
|
+
)
|
|
774
|
+
)
|
|
775
|
+
return findings
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _scan_line_for_astro_config_c2(
|
|
779
|
+
line: str, path: str, line_number: int
|
|
780
|
+
) -> list[SecretFinding]:
|
|
781
|
+
normalized_path = path.replace("\\", "/")
|
|
782
|
+
lowered_path = normalized_path.lower()
|
|
783
|
+
findings: list[SecretFinding] = []
|
|
784
|
+
|
|
785
|
+
if lowered_path.endswith("/.gitignore") or lowered_path == ".gitignore":
|
|
786
|
+
if ASTRO_GITIGNORE_HIDE_FILES.search(line):
|
|
787
|
+
findings.append(
|
|
788
|
+
SecretFinding(
|
|
789
|
+
rule_id="workflow.gitignore_hidden_pr_tooling",
|
|
790
|
+
path=path,
|
|
791
|
+
line=line_number,
|
|
792
|
+
reason="Gitignore hides PR automation/helper artifact names used in Astro config C2 reporting",
|
|
793
|
+
evidence="<redacted>",
|
|
794
|
+
)
|
|
795
|
+
)
|
|
796
|
+
return findings
|
|
797
|
+
|
|
798
|
+
if not _is_astro_config_path(normalized_path):
|
|
799
|
+
return findings
|
|
800
|
+
|
|
801
|
+
for rule_id, pattern, reason in ASTRO_CONFIG_C2_PATTERNS:
|
|
802
|
+
if pattern.search(line):
|
|
803
|
+
findings.append(
|
|
804
|
+
SecretFinding(
|
|
805
|
+
rule_id=rule_id,
|
|
806
|
+
path=path,
|
|
807
|
+
line=line_number,
|
|
808
|
+
reason=reason,
|
|
809
|
+
evidence="<redacted>",
|
|
810
|
+
)
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
if len(line) > 300 and re.search(r"[ \t]{80,}\S", line) and _astro_config_line_has_loader_signal(line):
|
|
814
|
+
findings.append(
|
|
815
|
+
SecretFinding(
|
|
816
|
+
rule_id="workflow.astro_config_hidden_payload_line",
|
|
817
|
+
path=path,
|
|
818
|
+
line=line_number,
|
|
819
|
+
reason="Astro config contains a long horizontally hidden executable-looking payload line",
|
|
820
|
+
evidence="<redacted>",
|
|
821
|
+
)
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
return findings
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _is_astro_config_path(path: str) -> bool:
|
|
828
|
+
name = path.replace("\\", "/").rsplit("/", 1)[-1].lower()
|
|
829
|
+
return name.startswith("astro.config.") and name.endswith((".js", ".cjs", ".mjs", ".ts", ".mts", ".cts"))
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _astro_config_line_has_loader_signal(line: str) -> bool:
|
|
833
|
+
return bool(
|
|
834
|
+
re.search(
|
|
835
|
+
r"createRequire|eval\s*\(|Function\s*\(|global\s*(?:\.|\[)|"
|
|
836
|
+
r"Buffer\.from|https?\.|\.request\s*\(|\.get\s*\(|fetch\s*\(|"
|
|
837
|
+
r"trongrid|aptoslabs|bsc-dataseed|publicnode",
|
|
838
|
+
line,
|
|
839
|
+
re.I,
|
|
840
|
+
)
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def _scan_line_for_openclaw_agent_exposure(
|
|
845
|
+
line: str, path: str, line_number: int
|
|
846
|
+
) -> list[SecretFinding]:
|
|
847
|
+
normalized_path = path.replace("\\", "/")
|
|
848
|
+
findings: list[SecretFinding] = []
|
|
849
|
+
|
|
850
|
+
if _is_dependency_metadata_path(normalized_path):
|
|
851
|
+
for match in OPENCLAW_VERSION_RE.finditer(line):
|
|
852
|
+
version = match.group(1)
|
|
853
|
+
if _compare_dotted_version(version, OPENCLAW_FIXED_VERSION) < 0:
|
|
854
|
+
findings.append(
|
|
855
|
+
SecretFinding(
|
|
856
|
+
rule_id="workflow.openclaw_vulnerable_version",
|
|
857
|
+
path=path,
|
|
858
|
+
line=line_number,
|
|
859
|
+
reason=f"OpenClaw {version} predates the {OPENCLAW_FIXED_VERSION} message-object prompt-boundary fix",
|
|
860
|
+
evidence="<redacted>",
|
|
861
|
+
)
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
if not _is_openclaw_config_path(normalized_path):
|
|
865
|
+
return findings
|
|
866
|
+
|
|
867
|
+
has_open_dm = OPENCLAW_OPEN_DM_POLICY_RE.search(line)
|
|
868
|
+
if has_open_dm and OPENCLAW_WILDCARD_ALLOW_RE.search(line):
|
|
869
|
+
findings.append(
|
|
870
|
+
SecretFinding(
|
|
871
|
+
rule_id="workflow.openclaw_open_dm_wildcard",
|
|
872
|
+
path=path,
|
|
873
|
+
line=line_number,
|
|
874
|
+
reason="OpenClaw config allows public inbound DMs with a wildcard allowlist",
|
|
875
|
+
evidence="<redacted>",
|
|
876
|
+
)
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
if has_open_dm and OPENCLAW_DISABLED_SANDBOX_RE.search(line):
|
|
880
|
+
findings.append(
|
|
881
|
+
SecretFinding(
|
|
882
|
+
rule_id="workflow.openclaw_open_dm_unsandboxed",
|
|
883
|
+
path=path,
|
|
884
|
+
line=line_number,
|
|
885
|
+
reason="OpenClaw config combines open inbound DMs with host/main/disabled sandbox mode",
|
|
886
|
+
evidence="<redacted>",
|
|
887
|
+
)
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
return findings
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def _is_dependency_metadata_path(path: str) -> bool:
|
|
894
|
+
name = path.replace("\\", "/").rsplit("/", 1)[-1].lower()
|
|
895
|
+
return name in {
|
|
896
|
+
"package.json",
|
|
897
|
+
"package-lock.json",
|
|
898
|
+
"npm-shrinkwrap.json",
|
|
899
|
+
"pnpm-lock.yaml",
|
|
900
|
+
"yarn.lock",
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _is_openclaw_config_path(path: str) -> bool:
|
|
905
|
+
name = path.replace("\\", "/").rsplit("/", 1)[-1].lower()
|
|
906
|
+
return name in {".crabbox.yaml", ".crabbox.yml"} or bool(
|
|
907
|
+
re.match(r"openclaw\.(?:json|jsonc|ya?ml|toml)$", name, re.I)
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def _compare_dotted_version(left: str, right: str) -> int:
|
|
912
|
+
left_parts = [int(part) for part in left.split(".")]
|
|
913
|
+
right_parts = [int(part) for part in right.split(".")]
|
|
914
|
+
length = max(len(left_parts), len(right_parts))
|
|
915
|
+
left_parts.extend([0] * (length - len(left_parts)))
|
|
916
|
+
right_parts.extend([0] * (length - len(right_parts)))
|
|
917
|
+
if left_parts == right_parts:
|
|
918
|
+
return 0
|
|
919
|
+
return -1 if left_parts < right_parts else 1
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def _scan_line_for_npm_v12_readiness(
|
|
923
|
+
line: str, path: str, line_number: int
|
|
924
|
+
) -> list[SecretFinding]:
|
|
925
|
+
normalized_path = path.replace("\\", "/")
|
|
926
|
+
name = normalized_path.rsplit("/", 1)[-1].lower()
|
|
927
|
+
findings: list[SecretFinding] = []
|
|
928
|
+
|
|
929
|
+
if name == ".npmrc":
|
|
930
|
+
match = NPM_BROAD_ALLOW_RE.search(line)
|
|
931
|
+
if match:
|
|
932
|
+
findings.append(
|
|
933
|
+
SecretFinding(
|
|
934
|
+
rule_id=f"workflow.npm_v12_broad_{match.group(1).replace('-', '_')}",
|
|
935
|
+
path=path,
|
|
936
|
+
line=line_number,
|
|
937
|
+
reason="Repo .npmrc broadly re-enables an npm v12 install-time execution or fetch surface",
|
|
938
|
+
evidence="<redacted>",
|
|
939
|
+
)
|
|
940
|
+
)
|
|
941
|
+
return findings
|
|
942
|
+
|
|
943
|
+
if not _is_dependency_metadata_path(normalized_path):
|
|
944
|
+
return findings
|
|
945
|
+
|
|
946
|
+
for match in NPM_VERSION_RE.finditer(line):
|
|
947
|
+
version = match.group(1)
|
|
948
|
+
if _compare_dotted_version(version, NPM_V12_PREPARE_MIN_VERSION) < 0:
|
|
949
|
+
findings.append(
|
|
950
|
+
SecretFinding(
|
|
951
|
+
rule_id="workflow.npm_v12_old_npm_pin",
|
|
952
|
+
path=path,
|
|
953
|
+
line=line_number,
|
|
954
|
+
reason=f"npm {version} is older than {NPM_V12_PREPARE_MIN_VERSION}, which surfaces npm v12 migration warnings",
|
|
955
|
+
evidence="<redacted>",
|
|
956
|
+
)
|
|
957
|
+
)
|
|
958
|
+
|
|
959
|
+
if NPM_REMOTE_TARBALL_RE.search(line):
|
|
960
|
+
findings.append(
|
|
961
|
+
SecretFinding(
|
|
962
|
+
rule_id="workflow.npm_v12_remote_tarball_dependency",
|
|
963
|
+
path=path,
|
|
964
|
+
line=line_number,
|
|
965
|
+
reason="npm dependency resolves from a remote tarball URL; npm v12 requires explicit --allow-remote approval",
|
|
966
|
+
evidence="<redacted>",
|
|
967
|
+
)
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
if NPM_GIT_DEPENDENCY_RE.search(line):
|
|
971
|
+
findings.append(
|
|
972
|
+
SecretFinding(
|
|
973
|
+
rule_id="workflow.npm_v12_git_dependency",
|
|
974
|
+
path=path,
|
|
975
|
+
line=line_number,
|
|
976
|
+
reason="npm dependency resolves from a Git source; npm v12 requires explicit --allow-git approval",
|
|
977
|
+
evidence="<redacted>",
|
|
978
|
+
)
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
return findings
|
|
982
|
+
|
|
983
|
+
|
|
602
984
|
def _is_workflow_or_script_path(path: str) -> bool:
|
|
603
985
|
lowered = path.lower()
|
|
604
986
|
if lowered.endswith((".md", ".mdx", ".txt", ".rst")):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: push-guard
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Local pre-push guard for likely secret leaks and private file paths.
|
|
5
5
|
Author: Dragon Lady
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -60,6 +60,14 @@ matched values.
|
|
|
60
60
|
- private key block markers
|
|
61
61
|
- generic long `api_key`, `token`, `secret`, or `password` assignments,
|
|
62
62
|
including underscore/dash-delimited names such as `AWS_SECRET_ACCESS_KEY`
|
|
63
|
+
- Astro config loader/C2 patterns in `astro.config.*` and related
|
|
64
|
+
`.gitignore` helper-artifact hiding, based on reported config-as-code
|
|
65
|
+
supply-chain abuse
|
|
66
|
+
- OpenClaw dependency versions before `2026.4.23` and risky OpenClaw
|
|
67
|
+
open-DM/wildcard/unsandboxed configuration lines
|
|
68
|
+
- npm v12 readiness regressions in pushed npm metadata, including old npm pins,
|
|
69
|
+
Git or remote tarball dependency sources, and broad repo `.npmrc` opt-ins for
|
|
70
|
+
install-time execution or dependency fetching
|
|
63
71
|
|
|
64
72
|
All evidence is redacted as `<redacted>`.
|
|
65
73
|
|
|
@@ -65,6 +65,28 @@ class PushGuardTests(unittest.TestCase):
|
|
|
65
65
|
self.assertEqual("secret.aws_access_key", findings[0].rule_id)
|
|
66
66
|
self.assertNotIn(secret, findings[0].evidence)
|
|
67
67
|
|
|
68
|
+
def test_detects_url_encoded_provider_token_shape(self):
|
|
69
|
+
secret = "gh" + "p_" + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJ" # push-guard: ignore
|
|
70
|
+
encoded = secret.replace("_", "%5F")
|
|
71
|
+
findings = scan_text_for_secrets(f"token={encoded}", path="config.txt")
|
|
72
|
+
|
|
73
|
+
self.assertEqual(1, len(findings))
|
|
74
|
+
self.assertEqual("secret.github_token", findings[0].rule_id)
|
|
75
|
+
self.assertNotIn(secret, findings[0].evidence)
|
|
76
|
+
|
|
77
|
+
def test_detects_split_provider_token_shape(self):
|
|
78
|
+
key = "OPENAI" + "_API_KEY"
|
|
79
|
+
prefix = "s" + "k-"
|
|
80
|
+
secret_tail = "abcdefghijklmnopqrstuvwxyzABCDEF" # push-guard: ignore
|
|
81
|
+
findings = scan_text_for_secrets(
|
|
82
|
+
f'{key} = "{prefix}" + "{secret_tail}"', # push-guard: ignore
|
|
83
|
+
path="config.py",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
self.assertEqual(1, len(findings))
|
|
87
|
+
self.assertEqual("secret.openai_token", findings[0].rule_id)
|
|
88
|
+
self.assertNotIn(secret_tail, findings[0].evidence)
|
|
89
|
+
|
|
68
90
|
def test_detects_generic_secret_assignment_without_returning_secret_value(self):
|
|
69
91
|
secret = "superlongsecretvalue1234567890"
|
|
70
92
|
findings = scan_text_for_secrets(f"api_key = '{secret}'", path="config.py")
|
|
@@ -252,6 +274,241 @@ class PushGuardTests(unittest.TestCase):
|
|
|
252
274
|
|
|
253
275
|
self.assertEqual([], _scan_diff(diff_text))
|
|
254
276
|
|
|
277
|
+
def test_scan_diff_blocks_ottercookie_npm_c2_and_backdoor_shapes(self):
|
|
278
|
+
diff_text = "\n".join(
|
|
279
|
+
[
|
|
280
|
+
"diff --git a/scripts/install.js b/scripts/install.js",
|
|
281
|
+
"index 1111111..2222222 100644",
|
|
282
|
+
"--- a/scripts/install.js",
|
|
283
|
+
"+++ b/scripts/install.js",
|
|
284
|
+
"@@ -1,0 +1,4 @@",
|
|
285
|
+
"+const pkg = 'bjs-lint-builder';", # push-guard: ignore
|
|
286
|
+
"+const c2 = 'https://cloudflareinsights.vercel.app/api/v1';", # push-guard: ignore
|
|
287
|
+
"+fs.appendFileSync(`${home}/.ssh/authorized_keys`, key);", # push-guard: ignore
|
|
288
|
+
"+child_process.execSync('sudo ufw allow 22/tcp');", # push-guard: ignore
|
|
289
|
+
]
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
findings = _scan_diff(diff_text)
|
|
293
|
+
rule_ids = {finding.rule_id for finding in findings}
|
|
294
|
+
|
|
295
|
+
self.assertIn("workflow.ottercookie_npm_package", rule_ids)
|
|
296
|
+
self.assertIn("workflow.ottercookie_vercel_c2", rule_ids)
|
|
297
|
+
self.assertIn("workflow.ottercookie_ssh_backdoor_shape", rule_ids)
|
|
298
|
+
|
|
299
|
+
def test_scan_diff_does_not_block_markdown_ottercookie_notes(self):
|
|
300
|
+
diff_text = "\n".join(
|
|
301
|
+
[
|
|
302
|
+
"diff --git a/docs/ottercookie.md b/docs/ottercookie.md",
|
|
303
|
+
"index 1111111..2222222 100644",
|
|
304
|
+
"--- a/docs/ottercookie.md",
|
|
305
|
+
"+++ b/docs/ottercookie.md",
|
|
306
|
+
"@@ -1,0 +1,3 @@",
|
|
307
|
+
"+Watch bjs-lint-builder package references.", # push-guard: ignore
|
|
308
|
+
"+Watch cloudflareinsights.vercel.app traffic.", # push-guard: ignore
|
|
309
|
+
"+Check authorized_keys and ufw allow 22/tcp changes.", # push-guard: ignore
|
|
310
|
+
]
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
self.assertEqual([], _scan_diff(diff_text))
|
|
314
|
+
|
|
315
|
+
def test_scan_diff_blocks_astro_config_loader_behavior(self):
|
|
316
|
+
diff_text = "\n".join(
|
|
317
|
+
[
|
|
318
|
+
"diff --git a/homepage/astro.config.mjs b/homepage/astro.config.mjs",
|
|
319
|
+
"index 1111111..2222222 100644",
|
|
320
|
+
"--- a/homepage/astro.config.mjs",
|
|
321
|
+
"+++ b/homepage/astro.config.mjs",
|
|
322
|
+
"@@ -1,0 +1,4 @@",
|
|
323
|
+
"+import { createRequire } from 'module';",
|
|
324
|
+
"+const require = createRequire(import.meta.url);",
|
|
325
|
+
"+const http = require('http');",
|
|
326
|
+
"+eval(stageBody);",
|
|
327
|
+
]
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
findings = _scan_diff(diff_text)
|
|
331
|
+
rule_ids = {finding.rule_id for finding in findings}
|
|
332
|
+
|
|
333
|
+
self.assertIn("workflow.astro_config_create_require", rule_ids)
|
|
334
|
+
self.assertIn("workflow.astro_config_network_loader", rule_ids)
|
|
335
|
+
self.assertIn("workflow.astro_config_eval_sink", rule_ids)
|
|
336
|
+
|
|
337
|
+
def test_scan_diff_blocks_astro_config_hidden_payload_line(self):
|
|
338
|
+
hidden_tail = " " * 320 + "global['x']=Buffer.from(payload);eval(stageBody);"
|
|
339
|
+
diff_text = "\n".join(
|
|
340
|
+
[
|
|
341
|
+
"diff --git a/astro.config.mjs b/astro.config.mjs",
|
|
342
|
+
"index 1111111..2222222 100644",
|
|
343
|
+
"--- a/astro.config.mjs",
|
|
344
|
+
"+++ b/astro.config.mjs",
|
|
345
|
+
"@@ -1,0 +1,1 @@",
|
|
346
|
+
"+export default defineConfig({});" + hidden_tail,
|
|
347
|
+
]
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
findings = _scan_diff(diff_text)
|
|
351
|
+
rule_ids = {finding.rule_id for finding in findings}
|
|
352
|
+
|
|
353
|
+
self.assertIn("workflow.astro_config_hidden_payload_line", rule_ids)
|
|
354
|
+
self.assertIn("workflow.astro_config_eval_sink", rule_ids)
|
|
355
|
+
|
|
356
|
+
def test_scan_diff_blocks_gitignore_hidden_pr_tooling(self):
|
|
357
|
+
diff_text = "\n".join(
|
|
358
|
+
[
|
|
359
|
+
"diff --git a/.gitignore b/.gitignore",
|
|
360
|
+
"index 1111111..2222222 100644",
|
|
361
|
+
"--- a/.gitignore",
|
|
362
|
+
"+++ b/.gitignore",
|
|
363
|
+
"@@ -1,0 +1,2 @@",
|
|
364
|
+
"+branch_structure.json",
|
|
365
|
+
"+temp_auto_push.bat",
|
|
366
|
+
]
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
findings = _scan_diff(diff_text)
|
|
370
|
+
rule_ids = {finding.rule_id for finding in findings}
|
|
371
|
+
|
|
372
|
+
self.assertIn("workflow.gitignore_hidden_pr_tooling", rule_ids)
|
|
373
|
+
|
|
374
|
+
def test_scan_diff_allows_plain_astro_config(self):
|
|
375
|
+
diff_text = "\n".join(
|
|
376
|
+
[
|
|
377
|
+
"diff --git a/astro.config.mjs b/astro.config.mjs",
|
|
378
|
+
"index 1111111..2222222 100644",
|
|
379
|
+
"--- a/astro.config.mjs",
|
|
380
|
+
"+++ b/astro.config.mjs",
|
|
381
|
+
"@@ -1,0 +1,6 @@",
|
|
382
|
+
"+import { defineConfig } from 'astro/config';",
|
|
383
|
+
"+import tailwindcss from '@tailwindcss/vite';",
|
|
384
|
+
"+export default defineConfig({",
|
|
385
|
+
"+ site: process.env.SITE_URL || 'https://example.org',",
|
|
386
|
+
"+ vite: { plugins: [tailwindcss()] },",
|
|
387
|
+
"+});",
|
|
388
|
+
]
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
self.assertEqual([], _scan_diff(diff_text))
|
|
392
|
+
|
|
393
|
+
def test_scan_diff_blocks_openclaw_vulnerable_version(self):
|
|
394
|
+
diff_text = "\n".join(
|
|
395
|
+
[
|
|
396
|
+
"diff --git a/package.json b/package.json",
|
|
397
|
+
"index 1111111..2222222 100644",
|
|
398
|
+
"--- a/package.json",
|
|
399
|
+
"+++ b/package.json",
|
|
400
|
+
"@@ -1,0 +1,5 @@",
|
|
401
|
+
"+{",
|
|
402
|
+
"+ \"dependencies\": {",
|
|
403
|
+
"+ \"openclaw\": \"2026.4.20\"",
|
|
404
|
+
"+ }",
|
|
405
|
+
"+}",
|
|
406
|
+
]
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
findings = _scan_diff(diff_text)
|
|
410
|
+
rule_ids = {finding.rule_id for finding in findings}
|
|
411
|
+
|
|
412
|
+
self.assertIn("workflow.openclaw_vulnerable_version", rule_ids)
|
|
413
|
+
|
|
414
|
+
def test_scan_diff_blocks_openclaw_open_dm_wildcard_and_unsandboxed(self):
|
|
415
|
+
diff_text = "\n".join(
|
|
416
|
+
[
|
|
417
|
+
"diff --git a/openclaw.yaml b/openclaw.yaml",
|
|
418
|
+
"index 1111111..2222222 100644",
|
|
419
|
+
"--- a/openclaw.yaml",
|
|
420
|
+
"+++ b/openclaw.yaml",
|
|
421
|
+
"@@ -1,0 +1,2 @@",
|
|
422
|
+
"+channels: { slack: { dmPolicy: \"open\", allowFrom: [\"*\"] } }",
|
|
423
|
+
"+agents.defaults.sandbox.mode: \"none\", dmPolicy: \"open\"",
|
|
424
|
+
]
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
findings = _scan_diff(diff_text)
|
|
428
|
+
rule_ids = {finding.rule_id for finding in findings}
|
|
429
|
+
|
|
430
|
+
self.assertIn("workflow.openclaw_open_dm_wildcard", rule_ids)
|
|
431
|
+
self.assertIn("workflow.openclaw_open_dm_unsandboxed", rule_ids)
|
|
432
|
+
|
|
433
|
+
def test_scan_diff_allows_openclaw_pairing_config_and_fixed_version(self):
|
|
434
|
+
diff_text = "\n".join(
|
|
435
|
+
[
|
|
436
|
+
"diff --git a/openclaw.yaml b/openclaw.yaml",
|
|
437
|
+
"index 1111111..2222222 100644",
|
|
438
|
+
"--- a/openclaw.yaml",
|
|
439
|
+
"+++ b/openclaw.yaml",
|
|
440
|
+
"@@ -1,0 +1,3 @@",
|
|
441
|
+
"+version: 2026.4.23",
|
|
442
|
+
"+channels: { slack: { dmPolicy: \"pairing\", allowFrom: [\"U123\"] } }",
|
|
443
|
+
"+agents.defaults.sandbox.mode: \"non-main\"",
|
|
444
|
+
]
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
self.assertEqual([], _scan_diff(diff_text))
|
|
448
|
+
|
|
449
|
+
def test_scan_diff_blocks_npm_v12_old_pin_and_nonregistry_sources(self):
|
|
450
|
+
diff_text = "\n".join(
|
|
451
|
+
[
|
|
452
|
+
"diff --git a/package.json b/package.json",
|
|
453
|
+
"index 1111111..2222222 100644",
|
|
454
|
+
"--- a/package.json",
|
|
455
|
+
"+++ b/package.json",
|
|
456
|
+
"@@ -1,0 +1,8 @@",
|
|
457
|
+
"+{",
|
|
458
|
+
"+ \"packageManager\": \"npm@11.15.0\",",
|
|
459
|
+
"+ \"dependencies\": {",
|
|
460
|
+
"+ \"tool-a\": \"github:example/tool-a\",",
|
|
461
|
+
"+ \"tool-b\": \"https://example.invalid/tool-b-1.0.0.tgz\"",
|
|
462
|
+
"+ }",
|
|
463
|
+
"+}",
|
|
464
|
+
]
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
findings = _scan_diff(diff_text)
|
|
468
|
+
rule_ids = {finding.rule_id for finding in findings}
|
|
469
|
+
|
|
470
|
+
self.assertIn("workflow.npm_v12_old_npm_pin", rule_ids)
|
|
471
|
+
self.assertIn("workflow.npm_v12_git_dependency", rule_ids)
|
|
472
|
+
self.assertIn("workflow.npm_v12_remote_tarball_dependency", rule_ids)
|
|
473
|
+
|
|
474
|
+
def test_scan_diff_blocks_broad_npm_v12_allow_config(self):
|
|
475
|
+
diff_text = "\n".join(
|
|
476
|
+
[
|
|
477
|
+
"diff --git a/.npmrc b/.npmrc",
|
|
478
|
+
"index 1111111..2222222 100644",
|
|
479
|
+
"--- a/.npmrc",
|
|
480
|
+
"+++ b/.npmrc",
|
|
481
|
+
"@@ -1,0 +1,3 @@",
|
|
482
|
+
"+allow-git=true",
|
|
483
|
+
"+allow-remote=all",
|
|
484
|
+
"+allow-scripts=*",
|
|
485
|
+
]
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
findings = _scan_diff(diff_text)
|
|
489
|
+
rule_ids = {finding.rule_id for finding in findings}
|
|
490
|
+
|
|
491
|
+
self.assertIn("workflow.npm_v12_broad_allow_git", rule_ids)
|
|
492
|
+
self.assertIn("workflow.npm_v12_broad_allow_remote", rule_ids)
|
|
493
|
+
self.assertIn("workflow.npm_v12_broad_allow_scripts", rule_ids)
|
|
494
|
+
|
|
495
|
+
def test_scan_diff_allows_npm_v12_ready_package_metadata(self):
|
|
496
|
+
diff_text = "\n".join(
|
|
497
|
+
[
|
|
498
|
+
"diff --git a/package.json b/package.json",
|
|
499
|
+
"index 1111111..2222222 100644",
|
|
500
|
+
"--- a/package.json",
|
|
501
|
+
"+++ b/package.json",
|
|
502
|
+
"@@ -1,0 +1,5 @@",
|
|
503
|
+
"+{",
|
|
504
|
+
"+ \"packageManager\": \"npm@11.16.0\",",
|
|
505
|
+
"+ \"dependencies\": { \"left-pad\": \"1.3.0\" }",
|
|
506
|
+
"+}",
|
|
507
|
+
]
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
self.assertEqual([], _scan_diff(diff_text))
|
|
511
|
+
|
|
255
512
|
def test_ignore_marker_suppresses_secret_and_workflow_rules(self):
|
|
256
513
|
# A line carrying the explicit opt-out marker is skipped by every rule,
|
|
257
514
|
# even when it contains a real token shape and an IOC pattern.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|