git-private2public 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.
- git_private2public-0.2.2/CHANGELOG.md +22 -0
- {git_private2public-0.2.0 → git_private2public-0.2.2}/PKG-INFO +1 -1
- {git_private2public-0.2.0 → git_private2public-0.2.2}/docs/ADVANCED.md +17 -1
- {git_private2public-0.2.0 → git_private2public-0.2.2}/docs/ADVANCED.ru.md +17 -1
- {git_private2public-0.2.0 → git_private2public-0.2.2}/git_private2public.py +182 -44
- {git_private2public-0.2.0 → git_private2public-0.2.2}/pyproject.toml +1 -1
- {git_private2public-0.2.0 → git_private2public-0.2.2}/tests/test_git_private2public.py +149 -2
- git_private2public-0.2.0/CHANGELOG.md +0 -10
- {git_private2public-0.2.0 → git_private2public-0.2.2}/.github/workflows/ci.yml +0 -0
- {git_private2public-0.2.0 → git_private2public-0.2.2}/.github/workflows/release.yml +0 -0
- {git_private2public-0.2.0 → git_private2public-0.2.2}/.gitignore +0 -0
- {git_private2public-0.2.0 → git_private2public-0.2.2}/LICENSE +0 -0
- {git_private2public-0.2.0 → git_private2public-0.2.2}/README.md +0 -0
- {git_private2public-0.2.0 → git_private2public-0.2.2}/README.ru.md +0 -0
- {git_private2public-0.2.0 → git_private2public-0.2.2}/example.yaml +0 -0
- {git_private2public-0.2.0 → git_private2public-0.2.2}/templates/publish.yml +0 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.2
|
|
4
|
+
|
|
5
|
+
- Fail closed with a concise redacted error when target-ref preflight times out.
|
|
6
|
+
- Prevent Git remote errors from echoing credential-bearing target URLs.
|
|
7
|
+
|
|
8
|
+
## 0.2.1
|
|
9
|
+
|
|
10
|
+
- Add `.gitpublic/public/` overlays for public-only files after sanitization.
|
|
11
|
+
- Keep snapshot publications at exactly one root commit after applying overlays.
|
|
12
|
+
- Reject overlay symlinks, special files, reserved paths, and destination escapes.
|
|
13
|
+
- Permit tracked `.gitpublic/scan` policy when every active rule is generic `regex:`.
|
|
14
|
+
|
|
15
|
+
## 0.2.0
|
|
16
|
+
|
|
17
|
+
- Add fail-closed scanning of every reachable blob, commit, and annotated tag.
|
|
18
|
+
- Apply built-in credential rules to every publish in addition to custom rules.
|
|
19
|
+
- Add one-commit `snapshot` mode for first public releases without private history.
|
|
20
|
+
- Require the public target to contain exactly the configured branches and tag set.
|
|
21
|
+
- Prevent pre-push publication from cloning a stale matching private remote.
|
|
22
|
+
- Remove all credential fragments from scan findings.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: git-private2public
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Like .gitignore, but for what goes public. Keep a sanitized public mirror of your private repo.
|
|
5
5
|
Project-URL: Homepage, https://github.com/megamen32/git-private2public
|
|
6
6
|
Project-URL: Repository, https://github.com/megamen32/git-private2public
|
|
@@ -16,7 +16,7 @@ private repo
|
|
|
16
16
|
-> push to public repo (.gitpublic/config)
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
Missing custom files mean “no custom rules for that step”. Built-in credential detection remains active for zero-config
|
|
19
|
+
Missing custom files mean “no custom rules for that step”. Built-in credential detection remains active for every publish, zero-config scan, and guard run.
|
|
20
20
|
|
|
21
21
|
| File | If present | If missing or empty |
|
|
22
22
|
|------|------------|---------------------|
|
|
@@ -26,6 +26,7 @@ Missing custom files mean “no custom rules for that step”. Built-in credenti
|
|
|
26
26
|
| `.gitpublic/scan` | Adds custom blocking patterns | Built-in credential rules still protect zero-config scans and guard mode |
|
|
27
27
|
| `.gitpublic/allow` | Exceptions for domain-like matches found by `scan` | No scan exceptions exist |
|
|
28
28
|
| `.gitpublic/domains` | Same as `allow` alias | Nothing is allowlisted |
|
|
29
|
+
| `.gitpublic/public/` | Adds or replaces files in the sanitized public tree | No public-only overlay is applied |
|
|
29
30
|
|
|
30
31
|
## `.gitpublic/config`
|
|
31
32
|
|
|
@@ -83,6 +84,17 @@ What it does:
|
|
|
83
84
|
|
|
84
85
|
It removes files from Git history in the temporary sanitized clone. It does not edit your private repo.
|
|
85
86
|
|
|
87
|
+
## `.gitpublic/public/`
|
|
88
|
+
|
|
89
|
+
Files below this directory are copied into the sanitized tree after delete and
|
|
90
|
+
replace rules. Relative paths are preserved, so
|
|
91
|
+
`.gitpublic/public/README.md` becomes public `README.md`. Overlay files are
|
|
92
|
+
committed and pass the same mandatory secret scan as every other public file.
|
|
93
|
+
|
|
94
|
+
In `snapshot` mode the overlay is applied before genesis is constructed, so
|
|
95
|
+
the public output still contains exactly one root commit. Symlinks, special
|
|
96
|
+
files, `.git` paths, and destination path escapes are rejected.
|
|
97
|
+
|
|
86
98
|
## `.gitpublic/replace`
|
|
87
99
|
|
|
88
100
|
One replacement per line:
|
|
@@ -113,6 +125,10 @@ regex:.*\.corp\.internal ==> example.com
|
|
|
113
125
|
|
|
114
126
|
## `.gitpublic/scan`
|
|
115
127
|
|
|
128
|
+
Generic scan policy may be tracked when every active rule starts with
|
|
129
|
+
`regex:`. Literal scan rules must remain untracked because the literal itself
|
|
130
|
+
may be private. `.gitpublic/replace` must always remain untracked.
|
|
131
|
+
|
|
116
132
|
`.gitpublic/scan` adds custom blocking rules. Built-in credential rules are always available for zero-config scans and guard mode.
|
|
117
133
|
|
|
118
134
|
One rule per line:
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
-> push в публичный репозиторий (.gitpublic/config)
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
Если какого-то файла нет, это значит “нет пользовательских правил для этого шага”. Встроенная проверка известных учётных данных
|
|
19
|
+
Если какого-то файла нет, это значит “нет пользовательских правил для этого шага”. Встроенная проверка известных учётных данных работает в publish, zero-config и guard-режимах.
|
|
20
20
|
|
|
21
21
|
| Файл | Если есть | Если файла нет или он пустой |
|
|
22
22
|
|------|-----------|-------------------------------|
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
| `.gitpublic/scan` | добавляет пользовательские блокирующие правила | встроенные правила продолжают работать в zero-config и guard-режимах |
|
|
27
27
|
| `.gitpublic/allow` | исключения для доменов, найденных проверкой `scan` | исключений для проверки нет |
|
|
28
28
|
| `.gitpublic/domains` | alias для `allow` | ничего не разрешается |
|
|
29
|
+
| `.gitpublic/public/` | добавляет или заменяет файлы в очищенном public-дереве | public-only overlay не применяется |
|
|
29
30
|
|
|
30
31
|
## `.gitpublic/config`
|
|
31
32
|
|
|
@@ -83,6 +84,17 @@ private/*.json
|
|
|
83
84
|
|
|
84
85
|
Это удаляет файлы во временном очищенном clone. Приватный репозиторий не меняется.
|
|
85
86
|
|
|
87
|
+
## `.gitpublic/public/`
|
|
88
|
+
|
|
89
|
+
Файлы из этой директории копируются в очищенное дерево после delete и
|
|
90
|
+
replace. Относительные пути сохраняются: `.gitpublic/public/README.md`
|
|
91
|
+
становится публичным `README.md`. Overlay-файлы попадают в public-коммит и
|
|
92
|
+
проходят ту же обязательную проверку секретов.
|
|
93
|
+
|
|
94
|
+
В `snapshot` overlay применяется до создания genesis, поэтом в публичном результате
|
|
95
|
+
по-прежнему ровно один root-коммит. Symlink, special-файлы, пути `.git` и выход
|
|
96
|
+
через destination path запрещены.
|
|
97
|
+
|
|
86
98
|
## `.gitpublic/replace`
|
|
87
99
|
|
|
88
100
|
По одной замене на строку:
|
|
@@ -113,6 +125,10 @@ regex:.*\.corp\.internal ==> example.com
|
|
|
113
125
|
|
|
114
126
|
## `.gitpublic/scan`
|
|
115
127
|
|
|
128
|
+
Общую scan-политику можно отслеживать в Git, если каждое активное правило
|
|
129
|
+
начинается с `regex:`. Literal scan-правила должны оставаться untracked, потому что
|
|
130
|
+
сам literal может быть приватным. `.gitpublic/replace` всегда должен оставаться untracked.
|
|
131
|
+
|
|
116
132
|
`.gitpublic/scan` добавляет свои блокирующие правила. Встроенные правила известных секретов доступны без настройки в zero-config и guard-режимах.
|
|
117
133
|
|
|
118
134
|
По одному правилу на строку:
|
|
@@ -26,6 +26,7 @@ import json
|
|
|
26
26
|
import os
|
|
27
27
|
import re
|
|
28
28
|
import shutil
|
|
29
|
+
import stat
|
|
29
30
|
import subprocess
|
|
30
31
|
import sys
|
|
31
32
|
import tempfile
|
|
@@ -34,7 +35,7 @@ from pathlib import Path
|
|
|
34
35
|
import time
|
|
35
36
|
from typing import Iterable
|
|
36
37
|
|
|
37
|
-
__version__ = "0.2.
|
|
38
|
+
__version__ = "0.2.2"
|
|
38
39
|
|
|
39
40
|
try:
|
|
40
41
|
import yaml
|
|
@@ -343,18 +344,31 @@ def clone_source(source: str, dest: Path) -> None:
|
|
|
343
344
|
# filter-repo works on bare clones too; keep it simple.
|
|
344
345
|
|
|
345
346
|
|
|
346
|
-
#
|
|
347
|
-
|
|
348
|
-
|
|
347
|
+
# Replacement rules may contain private literals and are never safe to track.
|
|
348
|
+
GITPUBLIC_REPLACE_FILE = ".gitpublic/replace"
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _is_generic_regex_rule(rule: str) -> bool:
|
|
352
|
+
"""Return true for a valid regex containing real pattern syntax."""
|
|
353
|
+
if not rule.startswith("regex:"):
|
|
354
|
+
return False
|
|
355
|
+
expression = rule[len("regex:"):]
|
|
356
|
+
try:
|
|
357
|
+
re.compile(expression)
|
|
358
|
+
except re.error:
|
|
359
|
+
return False
|
|
360
|
+
return bool(
|
|
361
|
+
re.search(r"(?<!\\)(?:\[|\(|\{|\.|\*|\+|\?|\|)|\\[dDsSwW]", expression)
|
|
362
|
+
)
|
|
349
363
|
|
|
350
364
|
|
|
351
365
|
def check_local_gitpublic_secrets_not_tracked(cwd: str) -> list[str]:
|
|
352
|
-
"""
|
|
366
|
+
"""Return tracked policy files that can reveal literal private values.
|
|
353
367
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
368
|
+
``replace`` is always rejected. A tracked ``scan`` is allowed only when
|
|
369
|
+
every active rule is explicitly ``regex:``; comments and blank lines are
|
|
370
|
+
ignored. This permits reusable provider signatures without allowing a
|
|
371
|
+
literal credential to become part of private Git history.
|
|
358
372
|
|
|
359
373
|
Returns the list of tracked secret-bearing files. Empty list = OK.
|
|
360
374
|
"""
|
|
@@ -365,8 +379,26 @@ def check_local_gitpublic_secrets_not_tracked(cwd: str) -> list[str]:
|
|
|
365
379
|
if res.returncode != 0:
|
|
366
380
|
return []
|
|
367
381
|
tracked = {f for f in res.stdout.splitlines() if f}
|
|
368
|
-
|
|
369
|
-
|
|
382
|
+
unsafe: list[str] = []
|
|
383
|
+
if GITPUBLIC_REPLACE_FILE in tracked:
|
|
384
|
+
unsafe.append(GITPUBLIC_REPLACE_FILE)
|
|
385
|
+
scan_path = ".gitpublic/scan"
|
|
386
|
+
if scan_path in tracked:
|
|
387
|
+
indexed = subprocess.run(
|
|
388
|
+
["git", "-C", cwd, "show", f":{scan_path}"],
|
|
389
|
+
capture_output=True,
|
|
390
|
+
text=True,
|
|
391
|
+
)
|
|
392
|
+
if indexed.returncode != 0:
|
|
393
|
+
unsafe.append(scan_path)
|
|
394
|
+
else:
|
|
395
|
+
rules = [
|
|
396
|
+
line.strip() for line in indexed.stdout.splitlines()
|
|
397
|
+
if line.strip() and not line.strip().startswith("#")
|
|
398
|
+
]
|
|
399
|
+
if any(not _is_generic_regex_rule(rule) for rule in rules):
|
|
400
|
+
unsafe.append(scan_path)
|
|
401
|
+
return sorted(unsafe)
|
|
370
402
|
|
|
371
403
|
|
|
372
404
|
def load_scan_rules(cwd: str, include_defaults: bool = False) -> list[str]:
|
|
@@ -567,24 +599,67 @@ def resolve_publish_source(configured_source: str, cwd: Path) -> str:
|
|
|
567
599
|
return expand_github_shorthand(configured_source)
|
|
568
600
|
|
|
569
601
|
|
|
570
|
-
|
|
571
|
-
"""
|
|
602
|
+
class OverlayError(RuntimeError):
|
|
603
|
+
"""Raised when a public overlay cannot be copied without escaping policy."""
|
|
572
604
|
|
|
573
|
-
Args:
|
|
574
|
-
repo: Normal working clone whose checked-out tree becomes the snapshot.
|
|
575
|
-
include_source_sha: Add the private source commit ID to the public message.
|
|
576
605
|
|
|
577
|
-
|
|
578
|
-
|
|
606
|
+
def _assert_within_worktree(path: Path, worktree: Path) -> None:
|
|
607
|
+
"""Reject a destination whose resolved parent escapes the worktree."""
|
|
608
|
+
try:
|
|
609
|
+
path.resolve(strict=False).relative_to(worktree.resolve())
|
|
610
|
+
except ValueError as exc:
|
|
611
|
+
raise OverlayError("overlay path escapes public worktree") from exc
|
|
579
612
|
|
|
580
|
-
|
|
581
|
-
|
|
613
|
+
|
|
614
|
+
def apply_public_overlay(overlay: Path, worktree: Path) -> list[str]:
|
|
615
|
+
"""Copy safe regular overlay files into the sanitized worktree.
|
|
616
|
+
|
|
617
|
+
Symlinks, special files, Git metadata paths, and destination symlink chains
|
|
618
|
+
are rejected so a private checkout cannot redirect writes outside the clone.
|
|
582
619
|
"""
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
620
|
+
if overlay.is_symlink():
|
|
621
|
+
raise OverlayError("public overlay root must not be a symlink")
|
|
622
|
+
if not overlay.exists():
|
|
623
|
+
return []
|
|
624
|
+
if not overlay.is_dir():
|
|
625
|
+
raise OverlayError("public overlay root must be a real directory")
|
|
626
|
+
|
|
627
|
+
copied: list[str] = []
|
|
628
|
+
for root_text, directories, files in os.walk(overlay, followlinks=False):
|
|
629
|
+
root = Path(root_text)
|
|
630
|
+
for directory in directories:
|
|
631
|
+
entry = root / directory
|
|
632
|
+
mode = entry.lstat().st_mode
|
|
633
|
+
if stat.S_ISLNK(mode):
|
|
634
|
+
raise OverlayError("public overlay contains a symlink")
|
|
635
|
+
if not stat.S_ISDIR(mode):
|
|
636
|
+
raise OverlayError("public overlay contains a non-regular directory entry")
|
|
637
|
+
for filename in files:
|
|
638
|
+
source = root / filename
|
|
639
|
+
relative = source.relative_to(overlay)
|
|
640
|
+
if not relative.parts or relative.parts[0] == ".git" or ".." in relative.parts:
|
|
641
|
+
raise OverlayError("public overlay path is reserved or escapes its root")
|
|
642
|
+
mode = source.lstat().st_mode
|
|
643
|
+
if stat.S_ISLNK(mode):
|
|
644
|
+
raise OverlayError("public overlay contains a symlink")
|
|
645
|
+
if not stat.S_ISREG(mode):
|
|
646
|
+
raise OverlayError("public overlay contains a non-regular file")
|
|
647
|
+
|
|
648
|
+
destination = worktree / relative
|
|
649
|
+
current = worktree
|
|
650
|
+
for part in relative.parts:
|
|
651
|
+
current = current / part
|
|
652
|
+
if current.is_symlink():
|
|
653
|
+
raise OverlayError("public overlay destination symlink would escape worktree")
|
|
654
|
+
_assert_within_worktree(destination.parent, worktree)
|
|
655
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
656
|
+
shutil.copy2(source, destination)
|
|
657
|
+
copied.append(relative.as_posix())
|
|
658
|
+
return copied
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _neutral_commit_environment() -> dict[str, str]:
|
|
662
|
+
"""Return deterministic, non-private author metadata for generated commits."""
|
|
588
663
|
env = os.environ.copy()
|
|
589
664
|
env.update(
|
|
590
665
|
{
|
|
@@ -596,6 +671,52 @@ def create_snapshot_root(repo: Path, include_source_sha: bool) -> str:
|
|
|
596
671
|
"GIT_COMMITTER_DATE": "2000-01-01T00:00:00Z",
|
|
597
672
|
}
|
|
598
673
|
)
|
|
674
|
+
return env
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def commit_public_overlay(repo: Path) -> bool:
|
|
678
|
+
"""Commit staged overlay changes with neutral metadata when changes exist."""
|
|
679
|
+
run(["git", "add", "-A"], cwd=str(repo))
|
|
680
|
+
diff = subprocess.run(
|
|
681
|
+
["git", "diff", "--cached", "--quiet"], cwd=str(repo), capture_output=True
|
|
682
|
+
)
|
|
683
|
+
if diff.returncode == 0:
|
|
684
|
+
return False
|
|
685
|
+
if diff.returncode != 1:
|
|
686
|
+
raise OverlayError("cannot inspect staged public overlay")
|
|
687
|
+
subprocess.run(
|
|
688
|
+
["git", "commit", "-m", "Apply public overlay"],
|
|
689
|
+
cwd=str(repo), capture_output=True, text=True, check=True,
|
|
690
|
+
env=_neutral_commit_environment(),
|
|
691
|
+
)
|
|
692
|
+
return True
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def create_snapshot_root(
|
|
696
|
+
repo: Path,
|
|
697
|
+
include_source_sha: bool,
|
|
698
|
+
source_sha: str | None = None,
|
|
699
|
+
) -> str:
|
|
700
|
+
"""Replace all history and refs with one neutral commit of the current tree.
|
|
701
|
+
|
|
702
|
+
Args:
|
|
703
|
+
repo: Normal working clone whose checked-out tree becomes the snapshot.
|
|
704
|
+
include_source_sha: Add the private source commit ID to the public message.
|
|
705
|
+
source_sha: Original source ID captured before filtering, when available.
|
|
706
|
+
|
|
707
|
+
Returns:
|
|
708
|
+
The new root commit ID.
|
|
709
|
+
|
|
710
|
+
Raises:
|
|
711
|
+
subprocess.CalledProcessError: If Git cannot construct the snapshot.
|
|
712
|
+
"""
|
|
713
|
+
if source_sha is None:
|
|
714
|
+
source_sha = run(["git", "rev-parse", "HEAD"], cwd=str(repo)).stdout.strip()
|
|
715
|
+
run(["git", "add", "-A"], cwd=str(repo))
|
|
716
|
+
tree = run(["git", "write-tree"], cwd=str(repo)).stdout.strip()
|
|
717
|
+
message = "Public source snapshot"
|
|
718
|
+
if include_source_sha:
|
|
719
|
+
message += f"\n\nSource-Commit: {source_sha}"
|
|
599
720
|
created = subprocess.run(
|
|
600
721
|
["git", "commit-tree", tree],
|
|
601
722
|
cwd=str(repo),
|
|
@@ -603,7 +724,7 @@ def create_snapshot_root(repo: Path, include_source_sha: bool) -> str:
|
|
|
603
724
|
capture_output=True,
|
|
604
725
|
text=True,
|
|
605
726
|
check=True,
|
|
606
|
-
env=
|
|
727
|
+
env=_neutral_commit_environment(),
|
|
607
728
|
)
|
|
608
729
|
commit = created.stdout.strip()
|
|
609
730
|
snapshot_ref = "refs/heads/git-private2public-snapshot"
|
|
@@ -618,12 +739,14 @@ def create_snapshot_root(repo: Path, include_source_sha: bool) -> str:
|
|
|
618
739
|
|
|
619
740
|
|
|
620
741
|
def _remote_ref_sha(url: str, ref: str) -> str:
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
742
|
+
try:
|
|
743
|
+
res = subprocess.run(
|
|
744
|
+
["git", "ls-remote", url, ref], capture_output=True, text=True, timeout=30
|
|
745
|
+
)
|
|
746
|
+
except subprocess.TimeoutExpired:
|
|
747
|
+
raise SystemExit("cannot read target ref: operation timed out") from None
|
|
624
748
|
if res.returncode != 0:
|
|
625
|
-
|
|
626
|
-
raise SystemExit(f"cannot read target ref {ref}: {err}")
|
|
749
|
+
raise SystemExit("cannot read target ref")
|
|
627
750
|
line = res.stdout.strip().splitlines()
|
|
628
751
|
return line[0].split()[0] if line else ""
|
|
629
752
|
|
|
@@ -637,8 +760,8 @@ def _remote_refs(url: str) -> dict[str, str]:
|
|
|
637
760
|
text=True,
|
|
638
761
|
timeout=30,
|
|
639
762
|
)
|
|
640
|
-
except subprocess.TimeoutExpired
|
|
641
|
-
raise SystemExit("cannot enumerate target refs: operation timed out") from
|
|
763
|
+
except subprocess.TimeoutExpired:
|
|
764
|
+
raise SystemExit("cannot enumerate target refs: operation timed out") from None
|
|
642
765
|
if result.returncode != 0:
|
|
643
766
|
raise SystemExit("cannot enumerate target refs")
|
|
644
767
|
refs: dict[str, str] = {}
|
|
@@ -767,6 +890,8 @@ def _break_alternates(work_gitdir: Path) -> bool:
|
|
|
767
890
|
|
|
768
891
|
def publish(config: Config, scan_only: bool = False, cwd: str | None = None, output_format: str = "text") -> int:
|
|
769
892
|
cwd = cwd or os.getcwd()
|
|
893
|
+
source_root = find_git_root(Path(cwd)) or Path(cwd).resolve()
|
|
894
|
+
overlay_path = source_root / ".gitpublic" / "public"
|
|
770
895
|
|
|
771
896
|
# Only .gitpublic/{replace,scan} may carry literal secrets; the rest
|
|
772
897
|
# (config / ignore / allow) is safe to commit. Refuse early if those
|
|
@@ -774,19 +899,17 @@ def publish(config: Config, scan_only: bool = False, cwd: str | None = None, out
|
|
|
774
899
|
tracked_secrets = check_local_gitpublic_secrets_not_tracked(cwd)
|
|
775
900
|
if tracked_secrets:
|
|
776
901
|
sys.stderr.write(
|
|
777
|
-
"\u2717
|
|
902
|
+
"\u2717 unsafe .gitpublic policy is tracked in the local repo "
|
|
778
903
|
"\u2014 refusing to publish.\n"
|
|
779
|
-
"
|
|
780
|
-
" pushing them would leak them to the public repo.\n"
|
|
904
|
+
" replace rules must stay untracked; tracked scan rules must be valid generic regex:.\n"
|
|
781
905
|
)
|
|
782
906
|
for f in tracked_secrets:
|
|
783
907
|
sys.stderr.write(f" {f}\n")
|
|
784
908
|
sys.stderr.write(
|
|
785
|
-
"\n Fix (keep
|
|
909
|
+
"\n Fix (keep generic regex scan policy tracked if useful):\n"
|
|
786
910
|
" echo '.gitpublic/replace' >> .gitignore\n"
|
|
787
|
-
"
|
|
788
|
-
"
|
|
789
|
-
" git commit -m 'untrack .gitpublic/{replace,scan}'\n"
|
|
911
|
+
" git rm --cached .gitpublic/replace\n"
|
|
912
|
+
" Convert scan literals to generic regex rules, or untrack .gitpublic/scan.\n"
|
|
790
913
|
)
|
|
791
914
|
return 1
|
|
792
915
|
|
|
@@ -819,6 +942,7 @@ def publish(config: Config, scan_only: bool = False, cwd: str | None = None, out
|
|
|
819
942
|
restore_gitdir = backup_info[1] if backup_info else None
|
|
820
943
|
print(f"▸ Cloning {mask_url(source_url)} ...", file=sys.stderr)
|
|
821
944
|
run(["git", "clone", "--no-local", source_url, str(work)])
|
|
945
|
+
original_source_sha = run(["git", "rev-parse", "HEAD"], cwd=str(work)).stdout.strip()
|
|
822
946
|
|
|
823
947
|
# Break object-store alternates so filter-repo cannot corrupt source
|
|
824
948
|
_break_alternates(work / ".git")
|
|
@@ -826,10 +950,6 @@ def publish(config: Config, scan_only: bool = False, cwd: str | None = None, out
|
|
|
826
950
|
# Detach origin (filter-repo removes it anyway)
|
|
827
951
|
run(["git", "remote", "remove", "origin"], cwd=str(work), check=False)
|
|
828
952
|
|
|
829
|
-
if config.mode == "snapshot":
|
|
830
|
-
print("▸ Creating one-commit public snapshot ...", file=sys.stderr)
|
|
831
|
-
create_snapshot_root(work, config.snapshot_include_source_sha)
|
|
832
|
-
|
|
833
953
|
# Write replace-text file
|
|
834
954
|
replace_file = tmp_path / "replacements.txt"
|
|
835
955
|
if replaces:
|
|
@@ -854,6 +974,24 @@ def publish(config: Config, scan_only: bool = False, cwd: str | None = None, out
|
|
|
854
974
|
sys.stderr.write(res.stderr)
|
|
855
975
|
sys.exit("git-filter-repo failed (rc=" + str(res.returncode) + ")" + bk + restore)
|
|
856
976
|
|
|
977
|
+
try:
|
|
978
|
+
overlay_files = apply_public_overlay(overlay_path, work)
|
|
979
|
+
except OverlayError as exc:
|
|
980
|
+
print(f"✗ Public overlay rejected: {exc}", file=sys.stderr)
|
|
981
|
+
return 1
|
|
982
|
+
if overlay_files:
|
|
983
|
+
print(f"▸ Applied public overlay ({len(overlay_files)} file(s)).", file=sys.stderr)
|
|
984
|
+
|
|
985
|
+
if config.mode == "snapshot":
|
|
986
|
+
print("▸ Creating one-commit public snapshot ...", file=sys.stderr)
|
|
987
|
+
create_snapshot_root(
|
|
988
|
+
work,
|
|
989
|
+
config.snapshot_include_source_sha,
|
|
990
|
+
source_sha=original_source_sha,
|
|
991
|
+
)
|
|
992
|
+
elif overlay_files:
|
|
993
|
+
commit_public_overlay(work)
|
|
994
|
+
|
|
857
995
|
# Scan the complete result before any ref can reach the target. Built-in
|
|
858
996
|
# credential patterns are mandatory and custom rules are additive.
|
|
859
997
|
print("▸ Scanning result and all reachable refs ...", file=sys.stderr)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
2
|
import subprocess
|
|
3
3
|
import json
|
|
4
|
+
import os
|
|
4
5
|
|
|
5
6
|
import pytest
|
|
6
7
|
|
|
@@ -114,8 +115,8 @@ def test_replace_rule_strips_separator_whitespace():
|
|
|
114
115
|
|
|
115
116
|
|
|
116
117
|
def test_version_constant_matches_package_version():
|
|
117
|
-
assert g.__version__ == "0.2.
|
|
118
|
-
assert 'version = "0.2.
|
|
118
|
+
assert g.__version__ == "0.2.2"
|
|
119
|
+
assert 'version = "0.2.2"' in (Path(__file__).parent.parent / "pyproject.toml").read_text()
|
|
119
120
|
|
|
120
121
|
|
|
121
122
|
def test_gitpublic_secret_check_finds_only_replace_and_scan(tmp_path: Path):
|
|
@@ -153,6 +154,46 @@ def test_gitpublic_secret_check_clean_when_only_safe_files_tracked(tmp_path: Pat
|
|
|
153
154
|
assert g.check_local_gitpublic_secrets_not_tracked(str(repo)) == []
|
|
154
155
|
|
|
155
156
|
|
|
157
|
+
def test_gitpublic_secret_check_allows_tracked_generic_regex_scan(tmp_path: Path):
|
|
158
|
+
repo = tmp_path / "r"
|
|
159
|
+
repo.mkdir()
|
|
160
|
+
subprocess.run(["git", "-C", str(repo), "init", "-q"], check=True)
|
|
161
|
+
gp = repo / ".gitpublic"
|
|
162
|
+
gp.mkdir()
|
|
163
|
+
(gp / "scan").write_text(
|
|
164
|
+
"# Generic provider signatures are safe policy\n"
|
|
165
|
+
"regex:ghp_[A-Za-z0-9]{30,}\n"
|
|
166
|
+
"regex:AKIA[0-9A-Z]{16}\n"
|
|
167
|
+
)
|
|
168
|
+
subprocess.run(["git", "-C", str(repo), "add", ".gitpublic/scan"], check=True)
|
|
169
|
+
|
|
170
|
+
assert g.check_local_gitpublic_secrets_not_tracked(str(repo)) == []
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_gitpublic_secret_check_rejects_tracked_scan_with_literal_rule(tmp_path: Path):
|
|
174
|
+
repo = tmp_path / "r"
|
|
175
|
+
repo.mkdir()
|
|
176
|
+
subprocess.run(["git", "-C", str(repo), "init", "-q"], check=True)
|
|
177
|
+
gp = repo / ".gitpublic"
|
|
178
|
+
gp.mkdir()
|
|
179
|
+
(gp / "scan").write_text("regex:generic-[0-9]+\nactual-private-value\n")
|
|
180
|
+
subprocess.run(["git", "-C", str(repo), "add", ".gitpublic/scan"], check=True)
|
|
181
|
+
|
|
182
|
+
assert g.check_local_gitpublic_secrets_not_tracked(str(repo)) == [".gitpublic/scan"]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_gitpublic_secret_check_rejects_regex_prefix_without_generic_expression(tmp_path: Path):
|
|
186
|
+
repo = tmp_path / "r"
|
|
187
|
+
repo.mkdir()
|
|
188
|
+
subprocess.run(["git", "-C", str(repo), "init", "-q"], check=True)
|
|
189
|
+
gp = repo / ".gitpublic"
|
|
190
|
+
gp.mkdir()
|
|
191
|
+
(gp / "scan").write_text("regex:actual-private-value\n")
|
|
192
|
+
subprocess.run(["git", "-C", str(repo), "add", ".gitpublic/scan"], check=True)
|
|
193
|
+
|
|
194
|
+
assert g.check_local_gitpublic_secrets_not_tracked(str(repo)) == [".gitpublic/scan"]
|
|
195
|
+
|
|
196
|
+
|
|
156
197
|
# ---- guard / default-pattern tests ----------------------------------------- #
|
|
157
198
|
|
|
158
199
|
def test_load_scan_rules_includes_defaults_when_requested(tmp_path: Path):
|
|
@@ -421,6 +462,22 @@ def test_remote_ref_sha_reads_exact_sha(monkeypatch):
|
|
|
421
462
|
assert g._remote_ref_sha("example", "refs/heads/main") == sha
|
|
422
463
|
|
|
423
464
|
|
|
465
|
+
def test_remote_ref_sha_timeout_fails_closed_without_url_leak(monkeypatch):
|
|
466
|
+
secret_url = "https://x-access-token:super-secret-token@example.test/private.git"
|
|
467
|
+
|
|
468
|
+
def timeout(*args, **kwargs):
|
|
469
|
+
raise subprocess.TimeoutExpired(args[0], timeout=30)
|
|
470
|
+
|
|
471
|
+
monkeypatch.setattr(g.subprocess, "run", timeout)
|
|
472
|
+
with pytest.raises(SystemExit) as error:
|
|
473
|
+
g._remote_ref_sha(secret_url, "refs/heads/main")
|
|
474
|
+
|
|
475
|
+
message = str(error.value)
|
|
476
|
+
assert message == "cannot read target ref: operation timed out"
|
|
477
|
+
assert secret_url not in message
|
|
478
|
+
assert "super-secret-token" not in message
|
|
479
|
+
|
|
480
|
+
|
|
424
481
|
@pytest.mark.parametrize(
|
|
425
482
|
"unexpected_ref",
|
|
426
483
|
["refs/heads/stale", "refs/tags/stale", "refs/notes/private"],
|
|
@@ -794,6 +851,96 @@ def test_publish_blocks_stale_target_ref_before_writing_configured_head(tmp_path
|
|
|
794
851
|
assert refs == ["refs/heads/stale"]
|
|
795
852
|
|
|
796
853
|
|
|
854
|
+
def test_snapshot_overlay_replaces_private_readme_without_source_history(tmp_path: Path):
|
|
855
|
+
source = tmp_path / "private"
|
|
856
|
+
target = tmp_path / "public.git"
|
|
857
|
+
source.mkdir()
|
|
858
|
+
subprocess.run(["git", "init", "-q", str(source)], check=True)
|
|
859
|
+
subprocess.run(["git", "init", "-q", "--bare", str(target)], check=True)
|
|
860
|
+
subprocess.run(["git", "-C", str(source), "config", "user.email", "private@example.test"], check=True)
|
|
861
|
+
subprocess.run(["git", "-C", str(source), "config", "user.name", "Private Person"], check=True)
|
|
862
|
+
(source / "README.md").write_text("private internal instructions\n")
|
|
863
|
+
subprocess.run(["git", "-C", str(source), "add", "README.md"], check=True)
|
|
864
|
+
subprocess.run(["git", "-C", str(source), "commit", "-q", "-m", "private history"], check=True)
|
|
865
|
+
overlay = source / ".gitpublic" / "public"
|
|
866
|
+
overlay.mkdir(parents=True)
|
|
867
|
+
(overlay / "README.md").write_text("# Public project\n")
|
|
868
|
+
cfg = g.Config(source=str(source), target=str(target), delete=["README.md"], mode="snapshot")
|
|
869
|
+
|
|
870
|
+
assert g.publish(cfg, cwd=str(source)) == 0
|
|
871
|
+
|
|
872
|
+
public_readme = subprocess.run(
|
|
873
|
+
["git", "--git-dir", str(target), "show", "refs/heads/main:README.md"],
|
|
874
|
+
capture_output=True, text=True, check=True,
|
|
875
|
+
).stdout
|
|
876
|
+
count = subprocess.run(
|
|
877
|
+
["git", "--git-dir", str(target), "rev-list", "--count", "refs/heads/main"],
|
|
878
|
+
capture_output=True, text=True, check=True,
|
|
879
|
+
).stdout.strip()
|
|
880
|
+
log = subprocess.run(
|
|
881
|
+
["git", "--git-dir", str(target), "log", "--format=%an%n%ae%n%s", "refs/heads/main"],
|
|
882
|
+
capture_output=True, text=True, check=True,
|
|
883
|
+
).stdout
|
|
884
|
+
assert public_readme == "# Public project\n"
|
|
885
|
+
assert count == "1"
|
|
886
|
+
assert "Private Person" not in log
|
|
887
|
+
assert "private history" not in log
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def test_public_overlay_rejects_symlinks_and_non_regular_files(tmp_path: Path):
|
|
891
|
+
overlay = tmp_path / "overlay"
|
|
892
|
+
work = tmp_path / "work"
|
|
893
|
+
overlay.mkdir()
|
|
894
|
+
work.mkdir()
|
|
895
|
+
(overlay / "link").symlink_to(tmp_path / "outside")
|
|
896
|
+
|
|
897
|
+
with pytest.raises(g.OverlayError, match="symlink"):
|
|
898
|
+
g.apply_public_overlay(overlay, work)
|
|
899
|
+
|
|
900
|
+
(overlay / "link").unlink()
|
|
901
|
+
fifo = overlay / "pipe"
|
|
902
|
+
fifo.parent.mkdir(exist_ok=True)
|
|
903
|
+
os.mkfifo(fifo)
|
|
904
|
+
with pytest.raises(g.OverlayError, match="non-regular"):
|
|
905
|
+
g.apply_public_overlay(overlay, work)
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def test_public_overlay_rejects_destination_symlink_escape(tmp_path: Path):
|
|
909
|
+
overlay = tmp_path / "overlay"
|
|
910
|
+
work = tmp_path / "work"
|
|
911
|
+
outside = tmp_path / "outside"
|
|
912
|
+
(overlay / "nested").mkdir(parents=True)
|
|
913
|
+
(overlay / "nested" / "README.md").write_text("public\n")
|
|
914
|
+
work.mkdir()
|
|
915
|
+
outside.mkdir()
|
|
916
|
+
(work / "nested").symlink_to(outside, target_is_directory=True)
|
|
917
|
+
|
|
918
|
+
with pytest.raises(g.OverlayError, match="destination symlink"):
|
|
919
|
+
g.apply_public_overlay(overlay, work)
|
|
920
|
+
|
|
921
|
+
assert not (outside / "README.md").exists()
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def test_overlay_content_is_scanned_before_push(tmp_path: Path):
|
|
925
|
+
source = tmp_path / "private"
|
|
926
|
+
target = tmp_path / "public.git"
|
|
927
|
+
source.mkdir()
|
|
928
|
+
subprocess.run(["git", "init", "-q", str(source)], check=True)
|
|
929
|
+
subprocess.run(["git", "init", "-q", "--bare", str(target)], check=True)
|
|
930
|
+
subprocess.run(["git", "-C", str(source), "config", "user.email", "[email protected]"], check=True)
|
|
931
|
+
subprocess.run(["git", "-C", str(source), "config", "user.name", "Test"], check=True)
|
|
932
|
+
(source / "safe.txt").write_text("safe\n")
|
|
933
|
+
subprocess.run(["git", "-C", str(source), "add", "safe.txt"], check=True)
|
|
934
|
+
subprocess.run(["git", "-C", str(source), "commit", "-q", "-m", "safe"], check=True)
|
|
935
|
+
overlay = source / ".gitpublic" / "public"
|
|
936
|
+
overlay.mkdir(parents=True)
|
|
937
|
+
(overlay / "credential.txt").write_text("ghp_" + "A" * 36 + "\n")
|
|
938
|
+
cfg = g.Config(source=str(source), target=str(target), mode="snapshot")
|
|
939
|
+
|
|
940
|
+
assert g.publish(cfg, cwd=str(source)) == 1
|
|
941
|
+
assert subprocess.run(["git", "--git-dir", str(target), "show-ref"], capture_output=True).returncode == 1
|
|
942
|
+
|
|
943
|
+
|
|
797
944
|
def test_doctor_reports_missing_tools_and_config(tmp_path: Path, monkeypatch, capsys):
|
|
798
945
|
monkeypatch.chdir(tmp_path)
|
|
799
946
|
monkeypatch.setattr(g.shutil, "which", lambda name: None)
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## 0.2.0
|
|
4
|
-
|
|
5
|
-
- Add fail-closed scanning of every reachable blob, commit, and annotated tag.
|
|
6
|
-
- Apply built-in credential rules to every publish in addition to custom rules.
|
|
7
|
-
- Add one-commit `snapshot` mode for first public releases without private history.
|
|
8
|
-
- Require the public target to contain exactly the configured branches and tag set.
|
|
9
|
-
- Prevent pre-push publication from cloning a stale matching private remote.
|
|
10
|
-
- Remove all credential fragments from scan findings.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|