push-guard 0.2.1__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.1
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.1"
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"
@@ -167,6 +167,74 @@ OTTERCOOKIE_NPM_PATTERNS = [
167
167
  ),
168
168
  ]
169
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
+
170
238
 
171
239
  # ---------------------------------------------------------------------------
172
240
  # Private-path rules.
@@ -540,6 +608,9 @@ def _scan_line(line: str, path: str, line_number: int) -> list[SecretFinding]:
540
608
  findings.extend(_scan_line_for_workflow_compromise(line, path, line_number))
541
609
  findings.extend(_scan_line_for_hades_pypi(line, path, line_number))
542
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))
543
614
 
544
615
  return findings
545
616
 
@@ -704,6 +775,212 @@ def _scan_line_for_ottercookie_npm(
704
775
  return findings
705
776
 
706
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
+
707
984
  def _is_workflow_or_script_path(path: str) -> bool:
708
985
  lowered = path.lower()
709
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.1
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
 
@@ -312,6 +312,203 @@ class PushGuardTests(unittest.TestCase):
312
312
 
313
313
  self.assertEqual([], _scan_diff(diff_text))
314
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
+
315
512
  def test_ignore_marker_suppresses_secret_and_workflow_rules(self):
316
513
  # A line carrying the explicit opt-out marker is skipped by every rule,
317
514
  # even when it contains a real token shape and an IOC pattern.
File without changes
File without changes