git-preflight-gate 0.1.6__py3-none-any.whl
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_preflight_gate-0.1.6.dist-info/METADATA +218 -0
- git_preflight_gate-0.1.6.dist-info/RECORD +8 -0
- git_preflight_gate-0.1.6.dist-info/WHEEL +5 -0
- git_preflight_gate-0.1.6.dist-info/entry_points.txt +2 -0
- git_preflight_gate-0.1.6.dist-info/licenses/LICENSE +21 -0
- git_preflight_gate-0.1.6.dist-info/top_level.txt +1 -0
- sim/__init__.py +8 -0
- sim/gate_workflows_ci.py +813 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: git-preflight-gate
|
|
3
|
+
Version: 0.1.6
|
|
4
|
+
Summary: Pre-write preflight gate (bash) and CI authority gate (Python composite action): fail-closed, standard library only except PyYAML for the CI extra.
|
|
5
|
+
Author: alexisromtz-lab
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/alexisromtz-lab/git-preflight-gate
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: PyYAML>=6.0
|
|
12
|
+
Provides-Extra: ci-authority-gate
|
|
13
|
+
Requires-Dist: PyYAML==6.0.3; extra == "ci-authority-gate"
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
<!-- HUELLA-README-EN-20261105 -->
|
|
17
|
+
**Language:** English · [Español](README.es.md)
|
|
18
|
+
|
|
19
|
+
# CI Authority Gate
|
|
20
|
+
|
|
21
|
+
**Repository:** [`git-preflight-gate`](https://github.com/alexisromtz-lab/git-preflight-gate) · **Marketplace:** [Install the action](https://github.com/marketplace/actions/ci-authority-gate)
|
|
22
|
+
|
|
23
|
+
**An integrity gate for autonomous AI agents: so that a bot does not dirty the tree
|
|
24
|
+
and does not commit concurrency collisions either.**
|
|
25
|
+
|
|
26
|
+
Before an agent writes a line, the gate demands a destination tuple —
|
|
27
|
+
`TARGET` (absolute) · `BLOB_PRE` · `HUELLA` · `LINEAS_PRE` — and returns a code that
|
|
28
|
+
**authorizes or blocks** the write. Without a prior run ending in `RC 0`, nothing is written.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install git-preflight-gate # Python CI gate (PyYAML comes with it)
|
|
34
|
+
ci-authority-gate /path/to/your/repo # RC 0 = pass · RC > 0 = fail-closed, cause printed
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Two doors, measured separately — do not confuse them:
|
|
38
|
+
|
|
39
|
+
| door | command | what it needs |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| **CI authority gate** | `pip install git-preflight-gate` | Python ≥ 3.9 + PyYAML (pip resolves it) |
|
|
42
|
+
| **pre-write gate (bash)** | `curl -fsSL https://raw.githubusercontent.com/alexisromtz-lab/git-preflight-gate/main/preflight-gate.sh -o preflight-gate.sh` | bash + git, **zero dependencies** |
|
|
43
|
+
|
|
44
|
+
The wheel ships the Python gate (`sim/`); the bash gate ships in the source distribution and in the repository, and is deliberately dependency-free so it can run on a machine with nothing installed. Neither is a wrapper around the other.
|
|
45
|
+
|
|
46
|
+
### The pre-write gate, at the shell
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
bash preflight-gate.sh /absolute/path/file.py <blob_pre> <exclusive_marker> <lineas_pre>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## The codes (fail-closed)
|
|
53
|
+
|
|
54
|
+
| RC | meaning |
|
|
55
|
+
|---:|---|
|
|
56
|
+
| `0` | PRE-FLIGHT OK — the target accepts an edit on `BLOB_PRE` |
|
|
57
|
+
| `10` | the object does not exist (creation) |
|
|
58
|
+
| `11` | **the marker already lives in the target** ⇒ collision: the row is already written |
|
|
59
|
+
| `12` | measured blob ≠ contract ⇒ the tree moved |
|
|
60
|
+
| `13` | dirty git state ⇒ unadjudicated modifications |
|
|
61
|
+
| `14` | the target is not inside a git tree ⇒ the gate cannot verify identity |
|
|
62
|
+
| `15` | line count differs from the contract |
|
|
63
|
+
| `20` | misuse (incomplete tuple, or `TARGET` not absolute) |
|
|
64
|
+
|
|
65
|
+
Every code comes out **with its cause printed**. A correct `RC` for the wrong reason is not a gate: it is a coincidence.
|
|
66
|
+
|
|
67
|
+
## Why `RC 11` is the case that matters
|
|
68
|
+
|
|
69
|
+
The most expensive defect of an agent is not picking the wrong path: it is **writing again something another hand already wrote**, and reporting success. The marker is an exclusive literal fragment of the text to be inserted; if it is already in the target, the write is rejected before the file is touched. The gate does not guess intent: it denies the collision.
|
|
70
|
+
|
|
71
|
+
## Why `TARGET` must be absolute
|
|
72
|
+
|
|
73
|
+
The gate rebases with `git -C <directory>` to measure the blob and the state. A relative path would be counted from **two different bases** (`cwd` and the object's directory) and could hash *another* file that does exist. Fail-closed: relative path ⇒ `RC 20`.
|
|
74
|
+
|
|
75
|
+
## Why `RC 14` exists
|
|
76
|
+
|
|
77
|
+
Without a git tree, `git status` returns 128 with empty output. A naive gate reads that empty output as "clean" and goes green. Here the impossibility is declared: without a tree there is no blob identity worth anything.
|
|
78
|
+
|
|
79
|
+
## Case bank
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
bash tests/must_fail.sh
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
9 cases, **9/9**, each verifying the code **and** the cause: `20` (incomplete tuple), `20` (relative path), `10`, `11`, `12`, `15`, `13`, `14`, and the positive control `0`. A bank without a positive control only proves the instrument knows how to say no.
|
|
86
|
+
|
|
87
|
+
## Out-of-band telemetry
|
|
88
|
+
|
|
89
|
+
Each run records 7 fields (`hora_utc target blob_pre blob_medido rc hash_arbol_ini hash_arbol_fin`) in `${GATE_TELEMETRIA_LOG:-$HOME/preflight-telemetria.log}`. Telemetry **cannot move the RC**, **cannot abort** and **cannot refresh the index** (`--no-optional-locks`). The path is never relative: a log inside the tree becomes an untracked file, and then the gate ends up measuring its own telemetry — the instrument counting itself.
|
|
90
|
+
|
|
91
|
+
## Declared limits
|
|
92
|
+
|
|
93
|
+
- On a nonexistent `TARGET` the gate stops at `RC 10` **before** comparing blob and lines: in the creation branch, the tuple is a **contract**, not a measurement. It is declared, not repaired (fail-closed is correct).
|
|
94
|
+
- It does not judge content: it certifies identity and collision, not correctness.
|
|
95
|
+
|
|
96
|
+
## The root Action: CI Authority Gate
|
|
97
|
+
|
|
98
|
+
The `action.yml` at this repository's root is a **composite action** running a second instrument, `sim/gate_workflows_ci.py`: a structural verifier of the authority topology in `.github/workflows/`. Five counting invariants, seven typed causes, `RC = min(causes)`, `UNIVERSO_VACIO` = cause `31` (zero files is failure, not approval).
|
|
99
|
+
|
|
100
|
+
Measured identity of the instrument copied into this tree:
|
|
101
|
+
|
|
102
|
+
| property | value |
|
|
103
|
+
|---|---|
|
|
104
|
+
| lines / bytes | 615 / 21470 |
|
|
105
|
+
| git blob | `ff9c69a944e7965b3fda0901f7745d80a9e85c7c` |
|
|
106
|
+
| sha256-16 | `073954a84f3c84fd` |
|
|
107
|
+
|
|
108
|
+
Using it as a consumer:
|
|
109
|
+
|
|
110
|
+
```yaml
|
|
111
|
+
- uses: alexisromtz-lab/git-preflight-gate@v0.1.4
|
|
112
|
+
with:
|
|
113
|
+
workflows-dir: .github/workflows
|
|
114
|
+
fail-on-red: "true"
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
<!-- HUELLA-RELEASE-V013-20261031 -->
|
|
118
|
+
`v0.1.3`, `v0.1.2` and `v0.1.1` run the same `action.yml` (`9ef420ec`) and the same `sim/gate_workflows_ci.py` (`ff9c69a9`): the gate's code did not change across the three. What changes is the adoption wrapper — `v0.1.2` added `templates/platino-gate.yml` and `v0.1.3` added `action-selftest.yml`, the manifest alignment and this documentation. If you pin by SHA, use the commit — not the tag.
|
|
119
|
+
|
|
120
|
+
The four inputs (`workflows-dir`, `root`, `python-bin`, `fail-on-red`) and the two
|
|
121
|
+
outputs (`rc`, `veredicto`) preserve the contract of the `action.yml` that this
|
|
122
|
+
repository **replaces**: any CI that pointed at the root action of a working repository
|
|
123
|
+
migrates by changing only the repository name in `uses:`; the rest of the `with:` block
|
|
124
|
+
does not change.
|
|
125
|
+
|
|
126
|
+
The action does not guess the domain: the instrument resolves a single point, `<root>/.github/workflows`. That is why `workflows-dir` routes to `--file` when the path exists and to `--root` when it does not. A `--root` that accepted any directory with YAML files would be editing the ruler so the demo fits.
|
|
127
|
+
|
|
128
|
+
Single dependency: `PyYAML==6.0.3`, declared as an optional extra (`pip install ".[ci-authority-gate]"`). The rest is stdlib. The instrument does not read the network, does not write files, does not use randomness and does not consult the clock.
|
|
129
|
+
|
|
130
|
+
Installable without cloning the repository, from the release wheel. The
|
|
131
|
+
`ci-authority-gate` extra is mandatory for the CLI: without PyYAML the instrument does
|
|
132
|
+
not measure and exits `RC=30` (fail-closed), not green.
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
pip install "git-preflight-gate[ci-authority-gate] @ https://github.com/alexisromtz-lab/git-preflight-gate/releases/download/v0.1.4/git_preflight_gate-0.1.4-py3-none-any.whl"
|
|
136
|
+
ci-authority-gate --self-test # built-in must-fail bank; touches no disk
|
|
137
|
+
ci-authority-gate --root . # measures <root>/.github/workflows and returns its RC
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
<!-- HUELLA-README-V014-20261106 -->
|
|
141
|
+
`v0.1.4` does not change the gate: `action.yml` (`9ef420ec`) and
|
|
142
|
+
`sim/gate_workflows_ci.py` (`ff9c69a9`) are the same blobs as in `v0.1.3`. What changes
|
|
143
|
+
is the package: `MANIFEST.in` makes the sdist carry the product, and the manifest
|
|
144
|
+
declares the `sim` package and the `ci-authority-gate` script. Before this change the
|
|
145
|
+
wheel was 6 768 B of `dist-info` only and no CLI existed. It is not on PyPI yet: the
|
|
146
|
+
only `pip` path today is the release URL.
|
|
147
|
+
|
|
148
|
+
Measured positive control: workflow with `permissions: contents: read` and `uses` pinned by full SHA ⇒ `CAUSAS PRESENTES: ninguna`, `VEREDICTO: PASA`, `RC=0`. Negative control: `pull_request_target` + `permissions: write-all` + `curl | bash` + `actions/checkout@v4` ⇒ `CAUSAS PRESENTES: [1, 2, 5]`, `RC=1`. Empty universe ⇒ `CAUSAS PRESENTES: [31]`, `RC=31`.
|
|
149
|
+
|
|
150
|
+
## The two instruments are not to be confused
|
|
151
|
+
|
|
152
|
+
| instrument | question | form |
|
|
153
|
+
|---|---|---|
|
|
154
|
+
| `preflight-gate.sh` | may this agent write *this file* now? | bash CLI, target tuple |
|
|
155
|
+
| `sim/gate_workflows_ci.py` | does this CI topology concentrate authority without control? | composite action + Python CLI |
|
|
156
|
+
|
|
157
|
+
The first protects a write; the second audits a workflow tree. They share the doctrine (fail-closed, printed cause, zero files = declared impossibility), not the code.
|
|
158
|
+
|
|
159
|
+
## Install it in your repository in 1 minute
|
|
160
|
+
|
|
161
|
+
<!-- HUELLA-BADGE-PLATINO-README-20261031 -->
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
mkdir -p .github/workflows
|
|
165
|
+
curl -fsSL https://raw.githubusercontent.com/alexisromtz-lab/git-preflight-gate/main/templates/platino-gate.yml \
|
|
166
|
+
-o .github/workflows/platino-gate.yml
|
|
167
|
+
git add .github/workflows/platino-gate.yml
|
|
168
|
+
git commit -m "ci: platino preflight gate"
|
|
169
|
+
git push
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
That is all: the workflow fires on the next `push` or PR.
|
|
173
|
+
|
|
174
|
+
### The file must be named `platino-gate.yml` (this is a contract, not advice)
|
|
175
|
+
|
|
176
|
+
If you rename it — `ci.yml`, `gate.yml`, `platino-gate.yaml` — the first job fails closed with `RC 20` and tells you why. The reason is measured, not aesthetic: the status badge resolves **by file name** (`.../workflow/status/YOUR-REPO/YOUR-REPO/platino-gate.yml`). With another name, the badge would read *your* CI workflow, not this gate, and would publish a green that measures nothing. The contract lives inside the workflow, not on this page.
|
|
177
|
+
|
|
178
|
+
Falsified by `tests/test_platino_gate_template.py`: 5 name cases and 5 structural cases, `RC 0` only if all 10 agree; a template mutated to accept any `.yml` returns `RC 1` naming the offending row.
|
|
179
|
+
|
|
180
|
+
## The badge
|
|
181
|
+
|
|
182
|
+
When the run finishes, the **Summary** tab publishes the badge already filled in with your `owner/repo`. There are two, and they are not the same:
|
|
183
|
+
|
|
184
|
+
| badge | what it claims | permissions it asks for |
|
|
185
|
+
|---|---|---|
|
|
186
|
+
| **status** (dynamic) | the real result of the most recent run of `platino-gate.yml` | `contents: read` |
|
|
187
|
+
| **seal** (static) | "this repository passed the gate" | `contents: read` |
|
|
188
|
+
|
|
189
|
+
```markdown
|
|
190
|
+
[](https://github.com/alexisromtz-lab/la-auditora)
|
|
191
|
+
|
|
192
|
+
[](https://github.com/alexisromtz-lab/la-auditora)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
The status one cannot lie: GitHub draws it from the run. **The seal can**: it is a literal anyone can paste in green without having run anything. Use it only when the run is green — the gate cannot verify that for you.
|
|
196
|
+
|
|
197
|
+
**Zero write permissions, in both cases.** The workflow does not touch your README, does not ask for `contents: write` and needs no service of ours: the badge is a `shields.io` URL that reads the public status of your own workflow. There is no endpoint to host, no token to expire.
|
|
198
|
+
|
|
199
|
+
### What it measures and what it does not
|
|
200
|
+
|
|
201
|
+
| measures | does not measure |
|
|
202
|
+
|---|---|
|
|
203
|
+
| authority topology in `.github/workflows/`: `permissions`, triggers, `uses` pinned by SHA, execution sinks | whether your code does what it says |
|
|
204
|
+
| seven typed causes, `RC = min(causes)`, `UNIVERSO_VACIO` = cause `31` | default `GITHUB_TOKEN` permissions at organization level |
|
|
205
|
+
| the tree you have, at the commit that runs | OIDC / Trusted Publishing trust in the destination registry |
|
|
206
|
+
|
|
207
|
+
The second block is printed on every run, under `RESIDUO DECLARADO`. A gate that does not declare its limits is not a gate: it is a promise.
|
|
208
|
+
|
|
209
|
+
### How the wrapper tests itself
|
|
210
|
+
|
|
211
|
+
<!-- HUELLA-BADGE-SELFTEST-DOC-20261031 -->
|
|
212
|
+
`action.yml` is not tested by reading its YAML: it is executed. On every push to `main`, the workflow [`action-selftest.yml`](.github/workflows/action-selftest.yml) runs the action twice with `uses: ./` — once on this repository itself, where it demands `rc=0` / `PASA`, and once on a poisoned fixture in `tests/fixtures/sumidero`, where it demands `rc=2` / `SUMIDERO_IMPUDO`. If the extra's installation (`pip install "<action_dir>[ci-authority-gate]"`) or the input routing breaks, it goes red here before going red on your runner.
|
|
213
|
+
|
|
214
|
+
A self-test that only knows how to be green discriminates nothing: that is why the second case demands the action **fail** with the correct cause.
|
|
215
|
+
|
|
216
|
+
## License
|
|
217
|
+
|
|
218
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
git_preflight_gate-0.1.6.dist-info/licenses/LICENSE,sha256=FiSMHs2bDfBb2jb4R3qAR6MRDeku8203wSaPlRg5tic,1069
|
|
2
|
+
sim/__init__.py,sha256=tkOQZJGTK4hlzrVJJkWJNcaAFuFhj3geBw8R79BS_a4,428
|
|
3
|
+
sim/gate_workflows_ci.py,sha256=tCpdndGc7RMPOtjd-IsCwOC8C-YopxFT6wiVkjK9bck,29941
|
|
4
|
+
git_preflight_gate-0.1.6.dist-info/METADATA,sha256=Hgj6cY4dYH8jPJoY17FsS0mPBqJIzC1XE7kBZ6tIj48,12834
|
|
5
|
+
git_preflight_gate-0.1.6.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
6
|
+
git_preflight_gate-0.1.6.dist-info/entry_points.txt,sha256=-KoZKb1gLlgvyqHZ2SwTeHJhYIage76hBTF_5M1XUAs,65
|
|
7
|
+
git_preflight_gate-0.1.6.dist-info/top_level.txt,sha256=lf-JWfiGnB0ZZ1Sd9fz8Zh9R5MCupexpcxpg21zinzs,4
|
|
8
|
+
git_preflight_gate-0.1.6.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Platino Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sim
|
sim/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Paquete del gate estructural de CI (`gate_workflows_ci`).
|
|
2
|
+
|
|
3
|
+
HUELLA-SIM-PAQUETE-20261106: el manifiesto declara este paquete para que el
|
|
4
|
+
wheel lleve el instrumento y `pip install` deje un CLI en el PATH. El action
|
|
5
|
+
composite NO lo usa: invoca el instrumento por ruta absoluta, porque en un
|
|
6
|
+
runner `github.action_path` apunta al checkout de la accion, no a
|
|
7
|
+
site-packages. Las dos rutas conviven y ninguna depende de la otra.
|
|
8
|
+
"""
|
sim/gate_workflows_ci.py
ADDED
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
gate_workflows_ci.py
|
|
5
|
+
====================
|
|
6
|
+
Verificador estructural DETERMINISTA de la topologia de autoridad en pipelines
|
|
7
|
+
de CI. Analiza el AST de los YAML de .github/workflows/ y aplica CINCO
|
|
8
|
+
invariantes contables. No lee red, no escribe archivos, no usa aleatoriedad,
|
|
9
|
+
no consulta el reloj.
|
|
10
|
+
|
|
11
|
+
INVARIANTE CENTRAL (forma corregida):
|
|
12
|
+
|
|
13
|
+
Autoridad(Job) > 0 => Ejecucion(repo U deps)(Job) = vacio
|
|
14
|
+
|
|
15
|
+
No se afirma "cero ejecucion": la autoridad tiene que ejecutar ALGO (descargar
|
|
16
|
+
un artefacto, correr el cliente nativo). Lo que se prohibe es que ese algo
|
|
17
|
+
provenga del repositorio o de sus dependencias.
|
|
18
|
+
|
|
19
|
+
UNIVERSO DECLARADO: .github/workflows/*.yml + .github/workflows/*.yaml
|
|
20
|
+
MOTOR: python3 stdlib + PyYAML (safe_load) -- sin red, sin escritura, sin azar
|
|
21
|
+
REGLA DE VEREDICTO: RC 0 (PASA) | RC > 0 (FALLO CERRADO, causa tipada)
|
|
22
|
+
|
|
23
|
+
TABLA DE CAUSAS (RC):
|
|
24
|
+
1 AUTORIDAD_CONTAMINADA Job con permisos de ESCRITURA que ejecuta codigo
|
|
25
|
+
de terceros mediante `run:` (gestor de paquetes,
|
|
26
|
+
interprete, descarga o mutacion de VCS). Un
|
|
27
|
+
cliente nativo de publicacion (ALLOWLIST_SINK)
|
|
28
|
+
no cuenta como ejecucion: publica artefactos.
|
|
29
|
+
2 SUMIDERO_IMPUDO Job sumidero (`id-token: write`) que hace
|
|
30
|
+
`actions/checkout`, invoca herramientas dinamicas
|
|
31
|
+
(npx/pipx) o no consume exclusivamente artefactos
|
|
32
|
+
descargados (`actions/download-artifact`).
|
|
33
|
+
3 DISPARADOR_ENVENENADO Trigger `pull_request_target` / `workflow_run`
|
|
34
|
+
con checkout explicito del commit del PR / head
|
|
35
|
+
dentro de un contexto con secretos o autoridad.
|
|
36
|
+
4 SECRETO_EXPUESTO `${{ secrets.* }}` interpolado dentro de un bloque
|
|
37
|
+
`run:` (el valor entra al argv y al log), o en
|
|
38
|
+
`env:` / `with:` de un job con disparador
|
|
39
|
+
privilegiado que alcanza contenido no confiable.
|
|
40
|
+
Un secreto pasado como INPUT de una accion no es
|
|
41
|
+
exposicion: es el mecanismo documentado. Ver el
|
|
42
|
+
dominio declarado de RC 4 mas abajo.
|
|
43
|
+
5 REF_MUTABLE `uses:` con tag flotante (@v*, @main, @master) en
|
|
44
|
+
vez de SHA-1 de 40 caracteres.
|
|
45
|
+
31 UNIVERSO_VACIO No hay archivos de workflow que medir.
|
|
46
|
+
32 YAML_INVALIDO El archivo no parsea.
|
|
47
|
+
|
|
48
|
+
RESIDUO DECLARADO (PLANO CIEGO FUERA DEL ARCHIVO):
|
|
49
|
+
1. Permisos por defecto del GITHUB_TOKEN a nivel de repositorio/organizacion.
|
|
50
|
+
2. Politicas de confianza OIDC / Trusted Publishing en el registro destino.
|
|
51
|
+
3. Ejecucion de codigo nativo no aislado en runtime (modificacion de deps).
|
|
52
|
+
4. Workflows reutilizables (`workflow_call`) y acciones compuestas alojadas
|
|
53
|
+
en repositorios ajenos.
|
|
54
|
+
=> El instrumento certifica la AUSENCIA de la forma `autoridad contaminada`
|
|
55
|
+
en el archivo que PUEDE leer. NO certifica el plano que no ve.
|
|
56
|
+
|
|
57
|
+
DOMINIO DECLARADO DE RC 1 (ancho, no umbral):
|
|
58
|
+
`actions/checkout` por si solo NO dispara RC 1. RC 1 mide EJECUCION
|
|
59
|
+
(`run:`), no presencia del arbol. La presencia del arbol en un job con
|
|
60
|
+
autoridad de publicacion es RC 2 (SUMIDERO_IMPUDO). La particion es
|
|
61
|
+
declarada, no silenciosa.
|
|
62
|
+
ALLOWLIST_SINK: el cliente nativo de publicacion (`npm publish ...`) queda
|
|
63
|
+
fuera del computo de ejecucion de terceros, porque el sumidero canonico
|
|
64
|
+
TIENE que ejecutarlo. Es una restriccion de DOMINIO declarada, no una
|
|
65
|
+
bajada de umbral: el conjunto admitido es exactamente el cliente nativo.
|
|
66
|
+
|
|
67
|
+
DOMINIO DECLARADO DE RC 4 (secreto + sumidero):
|
|
68
|
+
El defecto es la EXPOSICION, no la PRESENCIA del secreto. Hay dos sumideros
|
|
69
|
+
y solo dos:
|
|
70
|
+
SINK_SHELL -> `${{ secrets.* }}` interpolado dentro de `run:`: el valor
|
|
71
|
+
entra al argv del proceso y al log del step, y habilita
|
|
72
|
+
inyeccion de shell. Siempre RC 4.
|
|
73
|
+
SINK_CONTEXTO -> `${{ secrets.* }}` en `env:` (global o de job) o en `with:`
|
|
74
|
+
de un paso, EN UN JOB cuyo disparador es privilegiado
|
|
75
|
+
(`pull_request_target` / `workflow_run`) y que hace checkout
|
|
76
|
+
de una ref no confiable. RC 4.
|
|
77
|
+
Un secreto en `env:` de un job ordinario, o como INPUT (`with:`) de una accion
|
|
78
|
+
SHA-pinada, NO es exposicion: es la forma documentada de dar una credencial.
|
|
79
|
+
Se publica como OBSERVACION (se vio y no es sumidero), no como hallazgo: el
|
|
80
|
+
lector tiene que poder distinguir "no lo vi" de "lo vi y no lo es".
|
|
81
|
+
Medido 2026-11-06 sobre 47 workflows de 6 repos maduros: 23 jobs con secreto,
|
|
82
|
+
23/23 en `with:`, 0/23 en `run:`. Con la regla anterior el 100 % de la clase
|
|
83
|
+
era falso positivo (HUELLA-RC4-SUMIDERO-20261106).
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
import argparse
|
|
87
|
+
import io
|
|
88
|
+
import json
|
|
89
|
+
import os
|
|
90
|
+
import re
|
|
91
|
+
import sys
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
import yaml
|
|
95
|
+
except Exception as _e: # pragma: no cover
|
|
96
|
+
sys.stderr.write("MOTOR INCOMPLETO: PyYAML ausente (%s). Fail-closed.\n" % (_e,))
|
|
97
|
+
raise SystemExit(30)
|
|
98
|
+
|
|
99
|
+
MOTOR = "python3 stdlib + PyYAML %s (sin red, sin escritura, sin azar)" % (
|
|
100
|
+
getattr(yaml, "__version__", "?"),
|
|
101
|
+
)
|
|
102
|
+
UNIVERSO = ".github/workflows/*.yml + .github/workflows/*.yaml del arbol objetivo"
|
|
103
|
+
INVARIANTE = "Autoridad(Job) > 0 => Ejecucion(repo U deps)(Job) = vacio"
|
|
104
|
+
REGLA = "RC 0 (PASA) | RC > 0 (FALLO CERRADO, causa tipada)"
|
|
105
|
+
|
|
106
|
+
RESIDUO = (
|
|
107
|
+
"Permisos por defecto del GITHUB_TOKEN a nivel de repositorio u organizacion.",
|
|
108
|
+
"Politicas de confianza OIDC / Trusted Publishing en el registro destino (npm, PyPI, AWS).",
|
|
109
|
+
"Modificacion de dependencias en runtime si se ejecuta codigo nativo no aislado.",
|
|
110
|
+
"Workflows reutilizables (workflow_call) y acciones compuestas alojadas en repositorios ajenos.",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
CAUSAS = {
|
|
114
|
+
1: "AUTORIDAD_CONTAMINADA",
|
|
115
|
+
2: "SUMIDERO_IMPUDO",
|
|
116
|
+
3: "DISPARADOR_ENVENENADO",
|
|
117
|
+
4: "SECRETO_EXPUESTO",
|
|
118
|
+
5: "REF_MUTABLE",
|
|
119
|
+
31: "UNIVERSO_VACIO",
|
|
120
|
+
32: "YAML_INVALIDO",
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# ----------------------------------------------------------------------------
|
|
124
|
+
# Patrones. Todos son LEXICOS y se aplican linea por linea (dominio declarado:
|
|
125
|
+
# una linea de `run:` es la unidad de juicio).
|
|
126
|
+
# ----------------------------------------------------------------------------
|
|
127
|
+
PAT_EXEC = re.compile(
|
|
128
|
+
r"\b(npm|pnpm|yarn|npx|pipx|pip3|pip|cargo|rustc|go|gem|bundle|composer"
|
|
129
|
+
r"|make|cmake|node|deno|bun|python3|python|ruby|php|java|mvn|gradle|dotnet)\b"
|
|
130
|
+
)
|
|
131
|
+
PAT_FETCH = re.compile(r"\b(curl|wget)\b")
|
|
132
|
+
PAT_GITMUT = re.compile(r"\bgit\s+(push|commit|tag|remote|config)\b")
|
|
133
|
+
PAT_GH = re.compile(r"(^|[\s;&|(])gh\s+[a-z]")
|
|
134
|
+
# HUELLA-SINK-NATIVO-20261106: el allowlist cubria solo `npm publish`, asi que
|
|
135
|
+
# todo otro cliente nativo de publicacion (`gh release create`, `twine upload`,
|
|
136
|
+
# `docker push`, `cargo|poetry|uv publish`) contaba como "ejecuta codigo de
|
|
137
|
+
# terceros" y disparaba RC 1 en el patron de release canonico y endurecido.
|
|
138
|
+
# Medido en `pallets/flask`: `.github/workflows/publish.yaml :: create-release`
|
|
139
|
+
# (`permissions: contents: write`, sin checkout, `gh release create --draft`)
|
|
140
|
+
# -> RC 1 falso. La allowlist NO abre la puerta a `gh api` ni a `gh` en general:
|
|
141
|
+
# solo a los verbos de publicacion, que no ejecutan codigo del repositorio.
|
|
142
|
+
PAT_SINK_OK = re.compile(
|
|
143
|
+
r"^\s*(?:"
|
|
144
|
+
r"npm\s+publish\b"
|
|
145
|
+
r"|pnpm\s+publish\b"
|
|
146
|
+
r"|yarn\s+npm\s+publish\b"
|
|
147
|
+
r"|twine\s+upload\b"
|
|
148
|
+
r"|gh\s+release\s+(?:create|upload|edit)\b"
|
|
149
|
+
r"|docker\s+push\b"
|
|
150
|
+
r"|cargo\s+publish\b"
|
|
151
|
+
r"|poetry\s+publish\b"
|
|
152
|
+
r"|uv\s+publish\b"
|
|
153
|
+
r")"
|
|
154
|
+
)
|
|
155
|
+
PAT_DINAMICO = re.compile(r"\b(npx|pipx)\b")
|
|
156
|
+
PAT_SECRETS = re.compile(r"\$\{\{\s*secrets\.[A-Za-z0-9_]")
|
|
157
|
+
PAT_SHA40 = re.compile(r"^[0-9a-f]{40}$")
|
|
158
|
+
PAT_UNTRUSTED_REF = re.compile(r"github\.event\.(pull_request\.head|workflow_run\.head)")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _walk_str(obj):
|
|
162
|
+
"""Todos los literales de cadena de un subarbol YAML (claves incluidas)."""
|
|
163
|
+
if isinstance(obj, str):
|
|
164
|
+
yield obj
|
|
165
|
+
elif isinstance(obj, dict):
|
|
166
|
+
for k, v in obj.items():
|
|
167
|
+
yield str(k)
|
|
168
|
+
yield from _walk_str(v)
|
|
169
|
+
elif isinstance(obj, (list, tuple)):
|
|
170
|
+
for x in obj:
|
|
171
|
+
yield from _walk_str(x)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _clave_on(wf):
|
|
175
|
+
"""PyYAML (YAML 1.1) convierte la clave `on` en el booleano True."""
|
|
176
|
+
for k in (True, "on", "On", "ON"):
|
|
177
|
+
if k in wf:
|
|
178
|
+
return wf[k]
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _triggers(val):
|
|
183
|
+
if val is None:
|
|
184
|
+
return set()
|
|
185
|
+
if isinstance(val, str):
|
|
186
|
+
return {val}
|
|
187
|
+
if isinstance(val, (list, tuple)):
|
|
188
|
+
return {str(x) for x in val}
|
|
189
|
+
if isinstance(val, dict):
|
|
190
|
+
return {str(k) for k in val.keys()}
|
|
191
|
+
return set()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _mapa_perm(p):
|
|
195
|
+
if p is None:
|
|
196
|
+
return None
|
|
197
|
+
if isinstance(p, str):
|
|
198
|
+
v = p.strip().lower()
|
|
199
|
+
if v == "write-all":
|
|
200
|
+
return {"*": "write"}
|
|
201
|
+
if v == "read-all":
|
|
202
|
+
return {"*": "read"}
|
|
203
|
+
return {v: "write"}
|
|
204
|
+
if isinstance(p, dict):
|
|
205
|
+
return {str(k): str(v).strip().lower() for k, v in p.items()}
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def scopes_write(mapa):
|
|
210
|
+
if not mapa:
|
|
211
|
+
return set()
|
|
212
|
+
return {k for k, v in mapa.items() if v == "write"}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _pasos(job):
|
|
216
|
+
out = []
|
|
217
|
+
for s in (job.get("steps") or []):
|
|
218
|
+
if isinstance(s, dict):
|
|
219
|
+
out.append(s)
|
|
220
|
+
return out
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _runs(job):
|
|
224
|
+
return [str(s.get("run", "")) for s in _pasos(job) if "run" in s]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _uses(job):
|
|
228
|
+
return [str(s.get("uses", "")) for s in _pasos(job) if "uses" in s]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _todos_los_uses(wf):
|
|
232
|
+
out = []
|
|
233
|
+
jobs = wf.get("jobs")
|
|
234
|
+
if isinstance(jobs, dict):
|
|
235
|
+
for job in jobs.values():
|
|
236
|
+
if not isinstance(job, dict):
|
|
237
|
+
continue
|
|
238
|
+
if isinstance(job.get("uses"), str):
|
|
239
|
+
out.append(job["uses"])
|
|
240
|
+
out.extend(_uses(job))
|
|
241
|
+
return out
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _tiene_checkout(job):
|
|
245
|
+
return any(u.startswith("actions/checkout") for u in _uses(job))
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _tiene_download(job):
|
|
249
|
+
return any(u.startswith("actions/download-artifact") for u in _uses(job))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _secretos_por_ubicacion(wf, job):
|
|
253
|
+
"""Ubica cada `${{ secrets.* }}` en su sumidero real.
|
|
254
|
+
|
|
255
|
+
HUELLA-RC4-SUMIDERO-20261106 (medido 2026-11-06): RC 4 medía PRESENCIA de
|
|
256
|
+
`secrets.*`, no EXPOSICION. Sobre 47 workflows de 6 repos maduros
|
|
257
|
+
(pallets/flask, pydantic/pydantic, python-poetry/poetry, saleor/saleor,
|
|
258
|
+
wagtail/wagtail, encode/httpx) hay 23 jobs con secreto y 23/23 lo pasan en
|
|
259
|
+
`with:` a una accion SHA-pinada (codecov-action, create-github-app-token,
|
|
260
|
+
configure-aws-credentials, create-pull-request): 0/23 en `run:`. La forma
|
|
261
|
+
documentada de dar una credencial a una accion se reportaba como fuga: el
|
|
262
|
+
100 % de la clase era falso positivo.
|
|
263
|
+
|
|
264
|
+
Devuelve (en_run, en_insumo, obs):
|
|
265
|
+
en_run -- textos `run:` que interpolan un secreto (SINK_SHELL)
|
|
266
|
+
en_insumo -- valores secretos en `env:` (global / de job) o `with:` (SINK_CONTEXTO)
|
|
267
|
+
obs -- [(ubicacion, fuente)]: el secreto se vio y no es sumidero por si mismo
|
|
268
|
+
"""
|
|
269
|
+
en_run, en_insumo, obs = [], [], []
|
|
270
|
+
for etiqueta, src in (("env global", wf.get("env")), ("env de job", job.get("env"))):
|
|
271
|
+
if src:
|
|
272
|
+
vals = [x for x in _walk_str(src) if PAT_SECRETS.search(x)]
|
|
273
|
+
if vals:
|
|
274
|
+
en_insumo.extend(vals)
|
|
275
|
+
obs.append((etiqueta, etiqueta))
|
|
276
|
+
for s in _pasos(job):
|
|
277
|
+
if "run" in s and PAT_SECRETS.search(str(s.get("run", ""))):
|
|
278
|
+
en_run.append(str(s.get("run", "")))
|
|
279
|
+
if "with" in s:
|
|
280
|
+
vals = [x for x in _walk_str(s.get("with")) if PAT_SECRETS.search(x)]
|
|
281
|
+
if vals:
|
|
282
|
+
en_insumo.extend(vals)
|
|
283
|
+
obs.append(("with:", "with: %s" % (str(s.get("uses", "?"))[:60],)))
|
|
284
|
+
return en_run, en_insumo, obs
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _linea_ejecuta(linea):
|
|
288
|
+
"""Una linea de shell ejecuta codigo de terceros?"""
|
|
289
|
+
s = linea.strip()
|
|
290
|
+
if not s or s.startswith("#"):
|
|
291
|
+
return False
|
|
292
|
+
if PAT_SINK_OK.match(s): # ALLOWLIST_SINK: cliente nativo de publicacion
|
|
293
|
+
return False
|
|
294
|
+
return bool(
|
|
295
|
+
PAT_EXEC.search(s) or PAT_FETCH.search(s) or PAT_GITMUT.search(s) or PAT_GH.search(s)
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def ref_mutable(valor):
|
|
300
|
+
v = valor.strip()
|
|
301
|
+
if v.startswith("./") or v.startswith(".\\"):
|
|
302
|
+
return False
|
|
303
|
+
if v.startswith("docker://"):
|
|
304
|
+
return "@sha256:" not in v
|
|
305
|
+
if "@" not in v:
|
|
306
|
+
return True
|
|
307
|
+
return not PAT_SHA40.match(v.rsplit("@", 1)[1])
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ----------------------------------------------------------------------------
|
|
311
|
+
# NUCLEO
|
|
312
|
+
# ----------------------------------------------------------------------------
|
|
313
|
+
def analizar(texto, nombre):
|
|
314
|
+
"""Devuelve la lista de hallazgos [(rc, causa, archivo, job, detalle)]."""
|
|
315
|
+
return analizar_detallado(texto, nombre)[0]
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def analizar_detallado(texto, nombre):
|
|
319
|
+
"""Devuelve (hallazgos, observaciones).
|
|
320
|
+
|
|
321
|
+
Las observaciones son secretos que el instrumento VIO y no son sumidero por
|
|
322
|
+
si mismos. Viajan aparte para que `rc` no las cuente como defecto y para
|
|
323
|
+
que el lector pueda distinguir "no lo vi" de "lo vi y no lo es".
|
|
324
|
+
"""
|
|
325
|
+
hs = []
|
|
326
|
+
obs = {}
|
|
327
|
+
try:
|
|
328
|
+
wf = yaml.safe_load(texto)
|
|
329
|
+
except yaml.YAMLError as e:
|
|
330
|
+
return [{"rc": 32, "causa": CAUSAS[32], "archivo": nombre, "job": "-",
|
|
331
|
+
"detalle": str(e)[:200]}], []
|
|
332
|
+
if not isinstance(wf, dict):
|
|
333
|
+
return [{"rc": 32, "causa": CAUSAS[32], "archivo": nombre, "job": "-",
|
|
334
|
+
"detalle": "raiz del YAML no es un mapping"}], []
|
|
335
|
+
|
|
336
|
+
wf_perm = _mapa_perm(wf.get("permissions"))
|
|
337
|
+
triggers = _triggers(_clave_on(wf))
|
|
338
|
+
pr_target = bool(triggers & {"pull_request_target", "workflow_run"})
|
|
339
|
+
clase_trigger = ",".join(sorted(triggers & {"pull_request_target", "workflow_run"}))
|
|
340
|
+
|
|
341
|
+
# ---- RC 5: barrido global de referencias
|
|
342
|
+
for v in _todos_los_uses(wf):
|
|
343
|
+
if ref_mutable(v):
|
|
344
|
+
hs.append({"rc": 5, "causa": CAUSAS[5], "archivo": nombre, "job": "-",
|
|
345
|
+
"detalle": "uses con ref flotante: %s" % (v[:90],)})
|
|
346
|
+
|
|
347
|
+
# HUELLA-RC4-SUMIDERO-20261106: el bloque `env:` global ya no emite RC 4 por
|
|
348
|
+
# si mismo. Lo mide cada job que lo hereda (el env global se propaga a todos
|
|
349
|
+
# los jobs), y solo cuenta como exposicion si el job tiene sumidero.
|
|
350
|
+
|
|
351
|
+
jobs = wf.get("jobs")
|
|
352
|
+
if not isinstance(jobs, dict):
|
|
353
|
+
jobs = {}
|
|
354
|
+
|
|
355
|
+
for jn in sorted(jobs.keys(), key=str):
|
|
356
|
+
job = jobs.get(jn)
|
|
357
|
+
if not isinstance(job, dict):
|
|
358
|
+
continue
|
|
359
|
+
perm = _mapa_perm(job.get("permissions")) if "permissions" in job else wf_perm
|
|
360
|
+
sw = scopes_write(perm)
|
|
361
|
+
|
|
362
|
+
# ---- RC 1: AUTORIDAD_CONTAMINADA
|
|
363
|
+
if sw:
|
|
364
|
+
malas = []
|
|
365
|
+
for cmd in _runs(job):
|
|
366
|
+
for linea in cmd.splitlines():
|
|
367
|
+
if _linea_ejecuta(linea):
|
|
368
|
+
malas.append(linea.strip())
|
|
369
|
+
if malas:
|
|
370
|
+
hs.append({"rc": 1, "causa": CAUSAS[1], "archivo": nombre, "job": str(jn),
|
|
371
|
+
"detalle": "scopes=%s ejecuta: %s"
|
|
372
|
+
% (",".join(sorted(sw)), malas[0][:90])})
|
|
373
|
+
|
|
374
|
+
# ---- RC 2: SUMIDERO_IMPUDO
|
|
375
|
+
if ("id-token" in sw) or ("*" in sw):
|
|
376
|
+
motivos = []
|
|
377
|
+
if _tiene_checkout(job):
|
|
378
|
+
motivos.append("actions/checkout")
|
|
379
|
+
if any(PAT_DINAMICO.search(c) for c in _runs(job)):
|
|
380
|
+
motivos.append("herramienta dinamica (npx/pipx)")
|
|
381
|
+
if not _tiene_download(job):
|
|
382
|
+
motivos.append("sin actions/download-artifact")
|
|
383
|
+
if motivos:
|
|
384
|
+
hs.append({"rc": 2, "causa": CAUSAS[2], "archivo": nombre, "job": str(jn),
|
|
385
|
+
"detalle": "; ".join(motivos)})
|
|
386
|
+
|
|
387
|
+
# ---- secretos: ubicacion por sumidero (HUELLA-RC4-SUMIDERO-20261106)
|
|
388
|
+
en_run, en_insumo, obs_sec = _secretos_por_ubicacion(wf, job)
|
|
389
|
+
|
|
390
|
+
untrusted = False
|
|
391
|
+
if pr_target and _tiene_checkout(job):
|
|
392
|
+
refs = []
|
|
393
|
+
for s in _pasos(job):
|
|
394
|
+
if str(s.get("uses", "")).startswith("actions/checkout"):
|
|
395
|
+
w = s.get("with")
|
|
396
|
+
if isinstance(w, dict):
|
|
397
|
+
refs.append(str(w.get("ref", "")))
|
|
398
|
+
untrusted = any(PAT_UNTRUSTED_REF.search(r) for r in refs)
|
|
399
|
+
|
|
400
|
+
# ---- RC 3: DISPARADOR_ENVENENADO
|
|
401
|
+
contexto = bool(sw) or bool(en_run or en_insumo)
|
|
402
|
+
if pr_target and _tiene_checkout(job) and untrusted and contexto:
|
|
403
|
+
hs.append({"rc": 3, "causa": CAUSAS[3], "archivo": nombre, "job": str(jn),
|
|
404
|
+
"detalle": "trigger %s + checkout de ref no confiable en contexto privilegiado"
|
|
405
|
+
% (clase_trigger,)})
|
|
406
|
+
|
|
407
|
+
# ---- RC 4: SECRETO_EXPUESTO (secreto + sumidero, no presencia)
|
|
408
|
+
if en_run:
|
|
409
|
+
hs.append({"rc": 4, "causa": CAUSAS[4], "archivo": nombre, "job": str(jn),
|
|
410
|
+
"detalle": "secrets interpolado en run: (%d bloque(s)): el valor entra al argv"
|
|
411
|
+
% (len(en_run),)})
|
|
412
|
+
elif en_insumo and pr_target and untrusted:
|
|
413
|
+
hs.append({"rc": 4, "causa": CAUSAS[4], "archivo": nombre, "job": str(jn),
|
|
414
|
+
"detalle": "secrets en env/with bajo %s + checkout de ref no confiable"
|
|
415
|
+
" (el instrumento no evalua if:/labels: una etiqueta de"
|
|
416
|
+
" mantenedor no cubre el relanzamiento por synchronize)"
|
|
417
|
+
% (clase_trigger,)})
|
|
418
|
+
else:
|
|
419
|
+
for sitio, fuente in obs_sec:
|
|
420
|
+
obs.setdefault((sitio, fuente), set()).add(str(jn))
|
|
421
|
+
|
|
422
|
+
observaciones = [
|
|
423
|
+
{"archivo": nombre, "ubicacion": k[0], "fuente": k[1],
|
|
424
|
+
"job": ", ".join(sorted(js)) if len(js) < 6 else "%d jobs" % (len(js),)}
|
|
425
|
+
for k, js in sorted(obs.items())
|
|
426
|
+
]
|
|
427
|
+
return hs, observaciones
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# ----------------------------------------------------------------------------
|
|
431
|
+
# BANCO MUST-FAIL INCORPORADO (sobre cadenas: CERO escrituras a disco)
|
|
432
|
+
# ----------------------------------------------------------------------------
|
|
433
|
+
SHA_CHECKOUT = "11bd71901bbe5b1630ceea73d27597364c9af683"
|
|
434
|
+
SHA_SETUPNODE = "39370e3970a6d050c480ffad4ff0ed4d3fdee5af"
|
|
435
|
+
SHA_UPLOAD = "65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08"
|
|
436
|
+
SHA_DOWNLOAD = "fa0a91b85d4f404e444e00e005971372dc801d16"
|
|
437
|
+
|
|
438
|
+
C0_CANONICO = """
|
|
439
|
+
name: publicar
|
|
440
|
+
on:
|
|
441
|
+
release:
|
|
442
|
+
types: [published]
|
|
443
|
+
permissions:
|
|
444
|
+
contents: read
|
|
445
|
+
jobs:
|
|
446
|
+
build:
|
|
447
|
+
runs-on: ubuntu-latest
|
|
448
|
+
permissions:
|
|
449
|
+
contents: read
|
|
450
|
+
steps:
|
|
451
|
+
- uses: actions/checkout@%s
|
|
452
|
+
- uses: actions/setup-node@%s
|
|
453
|
+
with:
|
|
454
|
+
node-version: "20"
|
|
455
|
+
- run: npm ci --ignore-scripts
|
|
456
|
+
- run: npm pack
|
|
457
|
+
- run: sha256sum ./*.tgz > SHASUMS256.txt
|
|
458
|
+
- uses: actions/upload-artifact@%s
|
|
459
|
+
with:
|
|
460
|
+
name: dist
|
|
461
|
+
path: |
|
|
462
|
+
*.tgz
|
|
463
|
+
SHASUMS256.txt
|
|
464
|
+
publish:
|
|
465
|
+
needs: [build]
|
|
466
|
+
runs-on: ubuntu-latest
|
|
467
|
+
permissions:
|
|
468
|
+
id-token: write
|
|
469
|
+
actions: read
|
|
470
|
+
steps:
|
|
471
|
+
- uses: actions/download-artifact@%s
|
|
472
|
+
with:
|
|
473
|
+
name: dist
|
|
474
|
+
- run: sha256sum -c SHASUMS256.txt
|
|
475
|
+
- run: npm publish --provenance ./*.tgz
|
|
476
|
+
""" % (SHA_CHECKOUT, SHA_SETUPNODE, SHA_UPLOAD, SHA_DOWNLOAD)
|
|
477
|
+
|
|
478
|
+
C1_AUTORIDAD = """
|
|
479
|
+
name: contaminado
|
|
480
|
+
on: push
|
|
481
|
+
jobs:
|
|
482
|
+
build:
|
|
483
|
+
runs-on: ubuntu-latest
|
|
484
|
+
permissions:
|
|
485
|
+
contents: write
|
|
486
|
+
steps:
|
|
487
|
+
- uses: actions/checkout@%s
|
|
488
|
+
- run: npm ci
|
|
489
|
+
""" % (SHA_CHECKOUT,)
|
|
490
|
+
|
|
491
|
+
# HUELLA-SINK-NATIVO-20261106: control positivo -- el release canonico de
|
|
492
|
+
# `pallets/flask` (autoridad de escritura, sin checkout, `gh release create`)
|
|
493
|
+
# tiene que PASAR. Antes de la allowlist daba RC 1.
|
|
494
|
+
C7_PUBLICADOR_NATIVO = """
|
|
495
|
+
name: publish
|
|
496
|
+
on:
|
|
497
|
+
push:
|
|
498
|
+
tags: ['*']
|
|
499
|
+
permissions: {}
|
|
500
|
+
jobs:
|
|
501
|
+
create-release:
|
|
502
|
+
runs-on: ubuntu-latest
|
|
503
|
+
permissions:
|
|
504
|
+
contents: write
|
|
505
|
+
steps:
|
|
506
|
+
- uses: actions/download-artifact@%s
|
|
507
|
+
with:
|
|
508
|
+
name: dist
|
|
509
|
+
path: dist/
|
|
510
|
+
- name: create release
|
|
511
|
+
run: gh release create --draft --repo ${GITHUB_REPOSITORY} ${GITHUB_REF_NAME} dist/*
|
|
512
|
+
env:
|
|
513
|
+
GH_TOKEN: ${{ github.token }}
|
|
514
|
+
""" % (SHA_DOWNLOAD,)
|
|
515
|
+
|
|
516
|
+
# ... y su must-fail: `gh` NO es un sink en bloque. Bajo la misma autoridad,
|
|
517
|
+
# `gh api` mutando la configuracion del repositorio sigue siendo RC 1.
|
|
518
|
+
C8_GH_API_EN_AUTORIDAD = """
|
|
519
|
+
name: contaminado-gh
|
|
520
|
+
on: push
|
|
521
|
+
jobs:
|
|
522
|
+
alterar:
|
|
523
|
+
runs-on: ubuntu-latest
|
|
524
|
+
permissions:
|
|
525
|
+
contents: write
|
|
526
|
+
steps:
|
|
527
|
+
- run: gh api -X DELETE repos/${GITHUB_REPOSITORY}/branches/main/protection
|
|
528
|
+
"""
|
|
529
|
+
|
|
530
|
+
C2_SUMIDERO = """
|
|
531
|
+
name: sumidero
|
|
532
|
+
on:
|
|
533
|
+
release:
|
|
534
|
+
types: [published]
|
|
535
|
+
jobs:
|
|
536
|
+
publish:
|
|
537
|
+
runs-on: ubuntu-latest
|
|
538
|
+
permissions:
|
|
539
|
+
id-token: write
|
|
540
|
+
steps:
|
|
541
|
+
- run: npm publish --provenance ./*.tgz
|
|
542
|
+
"""
|
|
543
|
+
|
|
544
|
+
C3_DISPARADOR = """
|
|
545
|
+
name: pr-target
|
|
546
|
+
on: pull_request_target
|
|
547
|
+
jobs:
|
|
548
|
+
lint:
|
|
549
|
+
runs-on: ubuntu-latest
|
|
550
|
+
permissions:
|
|
551
|
+
pull-requests: write
|
|
552
|
+
steps:
|
|
553
|
+
- uses: actions/checkout@%s
|
|
554
|
+
with:
|
|
555
|
+
ref: ${{ github.event.pull_request.head.sha }}
|
|
556
|
+
""" % (SHA_CHECKOUT,)
|
|
557
|
+
|
|
558
|
+
D1_ENV_SECRETO_SIN_SUMIDERO = """
|
|
559
|
+
name: env-secreto
|
|
560
|
+
on: push
|
|
561
|
+
env:
|
|
562
|
+
TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
563
|
+
jobs:
|
|
564
|
+
build:
|
|
565
|
+
runs-on: ubuntu-latest
|
|
566
|
+
permissions:
|
|
567
|
+
contents: read
|
|
568
|
+
steps:
|
|
569
|
+
- run: echo ok
|
|
570
|
+
"""
|
|
571
|
+
|
|
572
|
+
D2_ACCION_CON_SECRETO = """
|
|
573
|
+
name: inputo-de-accion
|
|
574
|
+
on: push
|
|
575
|
+
jobs:
|
|
576
|
+
build:
|
|
577
|
+
runs-on: ubuntu-latest
|
|
578
|
+
permissions:
|
|
579
|
+
contents: read
|
|
580
|
+
steps:
|
|
581
|
+
- uses: actions/checkout@%s
|
|
582
|
+
with:
|
|
583
|
+
repository: alexisromtz-lab/privado
|
|
584
|
+
token: ${{ secrets.REPO_TOKEN }}
|
|
585
|
+
""" % (SHA_CHECKOUT,)
|
|
586
|
+
|
|
587
|
+
D3_SECRETO_EN_RUN = """
|
|
588
|
+
name: secreto-en-shell
|
|
589
|
+
on: push
|
|
590
|
+
jobs:
|
|
591
|
+
build:
|
|
592
|
+
runs-on: ubuntu-latest
|
|
593
|
+
permissions:
|
|
594
|
+
contents: read
|
|
595
|
+
steps:
|
|
596
|
+
- run: |
|
|
597
|
+
curl -sS -H "Authorization: Bearer ${{ secrets.API_TOKEN }}" https://example.test/hook
|
|
598
|
+
"""
|
|
599
|
+
|
|
600
|
+
D4_SECRETO_EN_CONTEXTO_NO_CONFIABLE = """
|
|
601
|
+
name: pr-target-con-secreto
|
|
602
|
+
on: pull_request_target
|
|
603
|
+
jobs:
|
|
604
|
+
comment:
|
|
605
|
+
runs-on: ubuntu-latest
|
|
606
|
+
permissions:
|
|
607
|
+
pull-requests: write
|
|
608
|
+
steps:
|
|
609
|
+
- uses: actions/checkout@%s
|
|
610
|
+
with:
|
|
611
|
+
ref: ${{ github.event.pull_request.head.sha }}
|
|
612
|
+
token: ${{ secrets.APP_TOKEN }}
|
|
613
|
+
""" % (SHA_CHECKOUT,)
|
|
614
|
+
|
|
615
|
+
C5_REF = """
|
|
616
|
+
name: flotante
|
|
617
|
+
on: push
|
|
618
|
+
jobs:
|
|
619
|
+
build:
|
|
620
|
+
runs-on: ubuntu-latest
|
|
621
|
+
permissions:
|
|
622
|
+
contents: read
|
|
623
|
+
steps:
|
|
624
|
+
- uses: actions/checkout@v4
|
|
625
|
+
- run: npm ci --ignore-scripts
|
|
626
|
+
"""
|
|
627
|
+
|
|
628
|
+
C6_DOS_CAUSAS = """
|
|
629
|
+
name: sumidero-sucio
|
|
630
|
+
on:
|
|
631
|
+
release:
|
|
632
|
+
types: [published]
|
|
633
|
+
jobs:
|
|
634
|
+
publish:
|
|
635
|
+
runs-on: ubuntu-latest
|
|
636
|
+
permissions:
|
|
637
|
+
id-token: write
|
|
638
|
+
steps:
|
|
639
|
+
- uses: actions/checkout@%s
|
|
640
|
+
- uses: actions/download-artifact@%s
|
|
641
|
+
with:
|
|
642
|
+
name: dist
|
|
643
|
+
- run: npx --yes provenance-publish ./dist/*.tgz
|
|
644
|
+
""" % (SHA_CHECKOUT, SHA_DOWNLOAD)
|
|
645
|
+
|
|
646
|
+
BANCO = (
|
|
647
|
+
("C0_CANONICO_DOS_JOBS", C0_CANONICO, 0, frozenset(), 0),
|
|
648
|
+
("C1_AUTORIDAD_CONTAMINADA", C1_AUTORIDAD, 1, frozenset([1]), 0),
|
|
649
|
+
("C2_SUMIDERO_IMPUDO", C2_SUMIDERO, 2, frozenset([2]), 0),
|
|
650
|
+
("C3_DISPARADOR_ENVENENADO", C3_DISPARADOR, 3, frozenset([3]), 0),
|
|
651
|
+
("C5_REF_MUTABLE", C5_REF, 5, frozenset([5]), 0),
|
|
652
|
+
("C6_DOS_CAUSAS_A_LA_VEZ", C6_DOS_CAUSAS, 1, frozenset([1, 2]), 0),
|
|
653
|
+
("C7_PUBLICADOR_NATIVO", C7_PUBLICADOR_NATIVO, 0, frozenset(), 0),
|
|
654
|
+
("C8_GH_API_EN_AUTORIDAD", C8_GH_API_EN_AUTORIDAD, 1, frozenset([1]), 0),
|
|
655
|
+
("D1_ENV_SECRETO_SIN_SUMIDERO", D1_ENV_SECRETO_SIN_SUMIDERO, 0, frozenset(), 1),
|
|
656
|
+
("D2_ACCION_CON_SECRETO", D2_ACCION_CON_SECRETO, 0, frozenset(), 1),
|
|
657
|
+
("D3_SECRETO_EN_RUN", D3_SECRETO_EN_RUN, 4, frozenset([4]), 0),
|
|
658
|
+
("D4_SECRETO_EN_CONTEXTO_NO_CONFIABLE", D4_SECRETO_EN_CONTEXTO_NO_CONFIABLE, 3,
|
|
659
|
+
frozenset([3, 4]), 0),
|
|
660
|
+
)
|
|
661
|
+
CONTROL = "C0_CANONICO_DOS_JOBS"
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def correr_banco():
|
|
665
|
+
filas = []
|
|
666
|
+
for nombre, texto, rc_esp, causas_esp, obs_esp in BANCO:
|
|
667
|
+
hs, obs = analizar_detallado(texto, "<memoria>/%s.yml" % nombre)
|
|
668
|
+
rc = min((h["rc"] for h in hs), default=0)
|
|
669
|
+
causas = frozenset(h["rc"] for h in hs)
|
|
670
|
+
ok = (rc == rc_esp) and (causas == causas_esp) and (len(obs) == obs_esp)
|
|
671
|
+
filas.append({
|
|
672
|
+
"caso": nombre,
|
|
673
|
+
"rc_esperado": rc_esp,
|
|
674
|
+
"rc_medido": rc,
|
|
675
|
+
"causas_esperadas": sorted(causas_esp),
|
|
676
|
+
"causas_medidas": sorted(causas),
|
|
677
|
+
"observaciones_esperadas": obs_esp,
|
|
678
|
+
"observaciones_medidas": len(obs),
|
|
679
|
+
"ok": ok,
|
|
680
|
+
"detalle": [h["detalle"] for h in hs],
|
|
681
|
+
})
|
|
682
|
+
banco_ok = all(f["ok"] for f in filas)
|
|
683
|
+
return filas, banco_ok
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
# ----------------------------------------------------------------------------
|
|
687
|
+
# SALIDA
|
|
688
|
+
# ----------------------------------------------------------------------------
|
|
689
|
+
def imprimir_cabecera():
|
|
690
|
+
print("UNIVERSO DECLARADO: %s" % UNIVERSO)
|
|
691
|
+
print("MOTOR: %s" % MOTOR)
|
|
692
|
+
print("REGLA DE VEREDICTO: %s" % REGLA)
|
|
693
|
+
print("INVARIANTE: %s" % INVARIANTE)
|
|
694
|
+
print("RESIDUO DECLARADO (PLANO CIEGO FUERA DE YAML):")
|
|
695
|
+
for r in RESIDUO:
|
|
696
|
+
print(" - %s" % r)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _fmt_causas(s):
|
|
700
|
+
return "{rc:%s|%s}" % (
|
|
701
|
+
",".join(str(x) for x in s) if s else "-",
|
|
702
|
+
",".join(CAUSAS.get(x, "?") for x in s) if s else "ninguna",
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def main(argv=None):
|
|
707
|
+
ap = argparse.ArgumentParser(
|
|
708
|
+
description="Gate estructural de CI: topologia de autoridad en .github/workflows/."
|
|
709
|
+
)
|
|
710
|
+
ap.add_argument("ruta", nargs="?", default=".", help="raiz del arbol objetivo (default: .)")
|
|
711
|
+
ap.add_argument("--root", dest="root", default=None, help="raiz explicita del arbol objetivo")
|
|
712
|
+
ap.add_argument("--file", dest="archivo", default=None, help="analizar UN archivo YAML")
|
|
713
|
+
ap.add_argument("--json", dest="json", action="store_true", help="salida JSON")
|
|
714
|
+
ap.add_argument("--self-test", dest="self_test", action="store_true",
|
|
715
|
+
help="correr el banco must-fail incorporado (sin tocar disco)")
|
|
716
|
+
ap.add_argument("--must-fail", dest="must_fail", action="store_true",
|
|
717
|
+
help="alias de --self-test: exige que cada causa provoque su RC")
|
|
718
|
+
ns = ap.parse_args(argv)
|
|
719
|
+
|
|
720
|
+
banco = ns.self_test or ns.must_fail
|
|
721
|
+
|
|
722
|
+
if banco:
|
|
723
|
+
filas, banco_ok = correr_banco()
|
|
724
|
+
if ns.json:
|
|
725
|
+
print(json.dumps({
|
|
726
|
+
"universo": UNIVERSO, "motor": MOTOR, "invariante": INVARIANTE,
|
|
727
|
+
"regla_de_veredicto": REGLA, "residuo_declarado": list(RESIDUO),
|
|
728
|
+
"causas": CAUSAS, "self_test": filas, "banco_ok": banco_ok,
|
|
729
|
+
"rc": 0 if banco_ok else 21,
|
|
730
|
+
}, indent=2, ensure_ascii=False))
|
|
731
|
+
else:
|
|
732
|
+
imprimir_cabecera()
|
|
733
|
+
print("")
|
|
734
|
+
print("BANCO MUST-FAIL (casos en memoria; el instrumento no escribe disco)")
|
|
735
|
+
print("%-28s %-20s %-20s %-6s %s" % ("CASO", "ESPERADO", "MEDIDO", "OBS", "OK"))
|
|
736
|
+
for f in filas:
|
|
737
|
+
print("%-28s %-20s %-20s %-6s %s" % (
|
|
738
|
+
f["caso"],
|
|
739
|
+
_fmt_causas(frozenset(f["causas_esperadas"])),
|
|
740
|
+
_fmt_causas(frozenset(f["causas_medidas"])),
|
|
741
|
+
"%d/%d" % (f["observaciones_medidas"], f["observaciones_esperadas"]),
|
|
742
|
+
"SI" if f["ok"] else "NO",
|
|
743
|
+
))
|
|
744
|
+
print("")
|
|
745
|
+
for f in filas:
|
|
746
|
+
for d in f["detalle"]:
|
|
747
|
+
print(" [%s] %s" % (f["caso"], d))
|
|
748
|
+
print("")
|
|
749
|
+
print("banco_ok = %s" % banco_ok)
|
|
750
|
+
return 0 if banco_ok else 21
|
|
751
|
+
|
|
752
|
+
raiz = ns.root or ns.ruta
|
|
753
|
+
archivos = []
|
|
754
|
+
if ns.archivo:
|
|
755
|
+
archivos = [ns.archivo]
|
|
756
|
+
else:
|
|
757
|
+
d = os.path.join(raiz, ".github", "workflows")
|
|
758
|
+
if os.path.isdir(d):
|
|
759
|
+
for fn in sorted(os.listdir(d)):
|
|
760
|
+
if fn.endswith(".yml") or fn.endswith(".yaml"):
|
|
761
|
+
archivos.append(os.path.join(d, fn))
|
|
762
|
+
|
|
763
|
+
hallazgos = []
|
|
764
|
+
observaciones = []
|
|
765
|
+
if not archivos:
|
|
766
|
+
hallazgos.append({"rc": 31, "causa": CAUSAS[31], "archivo": "-", "job": "-",
|
|
767
|
+
"detalle": "0 archivos en %s: nada que medir" % (os.path.join(raiz, ".github", "workflows"),)})
|
|
768
|
+
else:
|
|
769
|
+
for ruta in archivos:
|
|
770
|
+
try:
|
|
771
|
+
with io.open(ruta, "r", encoding="utf-8") as fh:
|
|
772
|
+
texto = fh.read()
|
|
773
|
+
except Exception as e:
|
|
774
|
+
hallazgos.append({"rc": 32, "causa": CAUSAS[32], "archivo": ruta, "job": "-",
|
|
775
|
+
"detalle": "no legible: %s" % (e,)})
|
|
776
|
+
continue
|
|
777
|
+
hs_archivo, obs_archivo = analizar_detallado(texto, ruta)
|
|
778
|
+
hallazgos.extend(hs_archivo)
|
|
779
|
+
observaciones.extend(obs_archivo)
|
|
780
|
+
|
|
781
|
+
causas = sorted({h["rc"] for h in hallazgos})
|
|
782
|
+
rc = min(causas) if causas else 0
|
|
783
|
+
|
|
784
|
+
if ns.json:
|
|
785
|
+
print(json.dumps({
|
|
786
|
+
"universo": UNIVERSO, "motor": MOTOR, "invariante": INVARIANTE,
|
|
787
|
+
"regla_de_veredicto": REGLA, "residuo_declarado": list(RESIDUO),
|
|
788
|
+
"causas": CAUSAS, "raiz": raiz, "archivos": archivos,
|
|
789
|
+
"hallazgos": hallazgos, "causas_presentes": causas, "rc": rc,
|
|
790
|
+
"observaciones": observaciones,
|
|
791
|
+
}, indent=2, ensure_ascii=False))
|
|
792
|
+
else:
|
|
793
|
+
imprimir_cabecera()
|
|
794
|
+
print("")
|
|
795
|
+
print("RAIZ: %s" % raiz)
|
|
796
|
+
print("ARCHIVOS MEDIDOS: %d" % len(archivos))
|
|
797
|
+
for h in hallazgos:
|
|
798
|
+
print(" RC %-2d %-22s %s :: %s :: %s"
|
|
799
|
+
% (h["rc"], h["causa"], h["archivo"], h["job"], h["detalle"]))
|
|
800
|
+
print("")
|
|
801
|
+
print("OBSERVACIONES (secreto visto, sin sumidero: NO es hallazgo): %d"
|
|
802
|
+
% len(observaciones))
|
|
803
|
+
for o in observaciones:
|
|
804
|
+
print(" OBS %-22s %s :: %s (%s)"
|
|
805
|
+
% (o["ubicacion"], o["archivo"], o["fuente"], o["job"]))
|
|
806
|
+
print("")
|
|
807
|
+
print("CAUSAS PRESENTES: %s" % (causas if causas else "ninguna"))
|
|
808
|
+
print("VEREDICTO: %s RC=%d" % ("PASA" if rc == 0 else "FALLO CERRADO", rc))
|
|
809
|
+
return rc
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
if __name__ == "__main__":
|
|
813
|
+
raise SystemExit(main())
|