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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: push-guard
3
- Version: 0.2.0
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
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "push-guard"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Local pre-push guard for likely secret leaks and private file paths."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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.0
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