auditagent-toolkit 0.2.0__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.
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+ import argparse
3
+ import json
4
+ import os
5
+ import shlex
6
+ import subprocess
7
+ import tempfile
8
+ from pathlib import Path
9
+ from datetime import datetime, timezone
10
+
11
+ def audit_exclusion_policy(
12
+ root: str | Path,
13
+ request_template: str | Path,
14
+ command_template: str,
15
+ option_key: str = "options.exclude_patterns",
16
+ timeout_seconds: int = 120
17
+ ) -> dict:
18
+ """Comprueba exclusiones reutilizables sobre archivos y directorios generados."""
19
+ started = datetime.now(timezone.utc).isoformat()
20
+ repo = Path(root).expanduser().resolve()
21
+ template = Path(request_template).expanduser().resolve()
22
+ patterns = ["*.bak", "state/*.result.json", "*.egg-info", "*.egg-info/**", "__pycache__", "__pycache__/**"]
23
+ def set_dotted(obj: dict, dotted: str, value) -> None:
24
+ cur = obj
25
+ parts = dotted.split(".")
26
+ for part in parts[:-1]: cur = cur.setdefault(part, {})
27
+ cur[parts[-1]] = value
28
+ def collect_paths(obj) -> set[str]:
29
+ found = set()
30
+ if isinstance(obj, dict):
31
+ for k, v in obj.items():
32
+ if k == "path" and isinstance(v, str): found.add(v)
33
+ found |= collect_paths(v)
34
+ elif isinstance(obj, list):
35
+ for v in obj: found |= collect_paths(v)
36
+ return found
37
+ with tempfile.TemporaryDirectory(prefix="auditagent-exclusion-") as temp:
38
+ t = Path(temp); workspace = t / "workspace"
39
+ (workspace / "state").mkdir(parents=True)
40
+ (workspace / "pkg.egg-info").mkdir()
41
+ (workspace / "__pycache__").mkdir()
42
+ (workspace / "keep.txt").write_text("keep\n", encoding="utf-8")
43
+ (workspace / "notes.bak").write_text("bak\n", encoding="utf-8")
44
+ (workspace / "state" / "scan.result.json").write_text("{}\n", encoding="utf-8")
45
+ (workspace / "pkg.egg-info" / "SOURCES.txt").write_text("gen\n", encoding="utf-8")
46
+ (workspace / "__pycache__" / "x.pyc").write_bytes(b"\0")
47
+ req = json.loads(template.read_text(encoding="utf-8"))
48
+ req["root"] = str(workspace); set_dotted(req, option_key, patterns)
49
+ request, output = t / "request.json", t / "output.json"
50
+ request.write_text(json.dumps(req, ensure_ascii=False, indent=2), encoding="utf-8")
51
+ cmd = [x.format(request=str(request), output=str(output), root=str(workspace)) for x in shlex.split(command_template)]
52
+ env = os.environ.copy(); env.update({"PYTHONDONTWRITEBYTECODE": "1", "TMPDIR": temp})
53
+ cp = subprocess.run(cmd, cwd=repo, env=env, text=True, capture_output=True, timeout=timeout_seconds, check=False)
54
+ data = json.loads(output.read_text(encoding="utf-8")) if output.exists() else {}
55
+ paths = collect_paths(data)
56
+ forbidden = {"notes.bak", "state/scan.result.json", "pkg.egg-info", "pkg.egg-info/SOURCES.txt", "__pycache__", "__pycache__/x.pyc"}
57
+ leaks = sorted(forbidden & paths)
58
+ missing_keep = "keep.txt" not in paths
59
+ findings = []
60
+ if leaks: findings.append({"severity": "MEDIUM", "title": "Artefactos excluidos presentes", "detail": ", ".join(leaks)})
61
+ if missing_keep: findings.append({"severity": "MEDIUM", "title": "Archivo válido ausente", "detail": "keep.txt no apareció."})
62
+ return {
63
+ "schema_version": "1.0", "module": "exclusion_policy",
64
+ "status": "FAIL" if leaks or missing_keep else "PASS",
65
+ "confidence": "high", "root": str(repo),
66
+ "summary": {"leaks": len(leaks), "keep_present": not missing_keep, "returncode": cp.returncode},
67
+ "findings": findings,
68
+ "evidence": [{"patterns": patterns, "paths": sorted(paths), "command": cmd, "stderr": cp.stderr[-2000:]}],
69
+ "limitations": ["No prueba todas las diferencias de semántica entre glob, fnmatch y sistemas de archivos case-insensitive."],
70
+ "started_at": started, "finished_at": datetime.now(timezone.utc).isoformat(),
71
+ }
72
+
73
+ if __name__ == "__main__":
74
+ p = argparse.ArgumentParser(description="Prueba de exclusiones.")
75
+ p.add_argument("root")
76
+ p.add_argument("--request", required=True)
77
+ p.add_argument("--command", required=True)
78
+ p.add_argument("--option-key", default="options.exclude_patterns")
79
+ p.add_argument("--timeout", type=int, default=120)
80
+ p.add_argument("--json-output")
81
+ a = p.parse_args()
82
+ r = audit_exclusion_policy(a.root, a.request, a.command, a.option_key, a.timeout)
83
+ s = json.dumps(r, ensure_ascii=False, indent=2)
84
+ Path(a.json_output).write_text(s + "\n", encoding="utf-8") if a.json_output else print(s)
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+ import argparse
3
+ import json
4
+ import re
5
+ import subprocess
6
+ from pathlib import Path
7
+ from datetime import datetime, timezone
8
+
9
+ def audit_git_state(root: str | Path = ".") -> dict:
10
+ """Audita estado, historial, ignorados e identidad Git sin modificar el repositorio."""
11
+ started = datetime.now(timezone.utc).isoformat()
12
+ repo = Path(root).expanduser().resolve()
13
+ def run(*args: str) -> tuple[int, str, str]:
14
+ cp = subprocess.run(
15
+ ["git", *args], cwd=repo, text=True,
16
+ capture_output=True, timeout=30, check=False
17
+ )
18
+ return cp.returncode, cp.stdout.strip(), cp.stderr.strip()
19
+ code, top, err = run("rev-parse", "--show-toplevel")
20
+ if code != 0:
21
+ return {
22
+ "schema_version": "1.0", "module": "git_state", "status": "NOT_RUN",
23
+ "confidence": "high", "root": str(repo), "summary": {"is_git_repo": False},
24
+ "findings": [{"severity": "OBSERVATION", "title": "No es repositorio Git", "detail": err or "git rev-parse falló"}],
25
+ "evidence": [], "limitations": [], "started_at": started,
26
+ "finished_at": datetime.now(timezone.utc).isoformat(),
27
+ }
28
+ repo = Path(top)
29
+ _, status, _ = run("status", "--short", "--branch", "--ignored")
30
+ _, tracked, _ = run("ls-files")
31
+ _, history, _ = run("log", "--format=%H%x09%an%x09%ae%x09%s", "--all", "-n", "100")
32
+ _, remotes, _ = run("remote", "-v")
33
+ findings = []
34
+ email_pattern = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
35
+ identities = []
36
+ for line in history.splitlines():
37
+ parts = line.split("\t")
38
+ if len(parts) >= 4:
39
+ commit, name, email, subject = parts[:4]
40
+ identities.append({"commit": commit, "name": name, "email": email, "subject": subject})
41
+ if email_pattern.match(email) and not email.endswith((".invalid", "@users.noreply.github.com")):
42
+ findings.append({
43
+ "severity": "LOW", "title": "Correo personal o publicable en historial",
44
+ "detail": f"El commit {commit[:12]} contiene una dirección de correo no anonimizada.",
45
+ "path": ".git"
46
+ })
47
+ dirty = any(
48
+ line and not line.startswith("##") and not line.startswith("!!")
49
+ for line in status.splitlines()
50
+ )
51
+ if dirty:
52
+ findings.append({"severity": "LOW", "title": "Árbol de trabajo con cambios", "detail": "Existen cambios no confirmados o no rastreados."})
53
+ return {
54
+ "schema_version": "1.0", "module": "git_state",
55
+ "status": "WARN" if findings else "PASS",
56
+ "confidence": "high", "root": str(repo),
57
+ "summary": {
58
+ "is_git_repo": True,
59
+ "dirty": dirty,
60
+ "tracked_files": len([x for x in tracked.splitlines() if x]),
61
+ "commits_inspected": len(identities),
62
+ "remotes": len([x for x in remotes.splitlines() if x]),
63
+ },
64
+ "findings": findings,
65
+ "evidence": [{"status": status, "identities": identities, "remotes": remotes.splitlines()}],
66
+ "limitations": ["La ausencia de secretos en el historial requiere un módulo específico de contenido sensible."],
67
+ "started_at": started,
68
+ "finished_at": datetime.now(timezone.utc).isoformat(),
69
+ }
70
+
71
+ if __name__ == "__main__":
72
+ p = argparse.ArgumentParser(description="Auditoría Git independiente.")
73
+ p.add_argument("root", nargs="?", default=".")
74
+ p.add_argument("--json-output")
75
+ a = p.parse_args()
76
+ r = audit_git_state(a.root)
77
+ s = json.dumps(r, ensure_ascii=False, indent=2)
78
+ Path(a.json_output).write_text(s + "\n", encoding="utf-8") if a.json_output else print(s)
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+ import argparse
3
+ import json
4
+ import os
5
+ import shlex
6
+ import subprocess
7
+ import tempfile
8
+ from pathlib import Path
9
+ from datetime import datetime, timezone
10
+
11
+ def audit_hidden_policy(
12
+ root: str | Path,
13
+ request_template: str | Path,
14
+ command_template: str,
15
+ option_key: str = "options.include_hidden",
16
+ timeout_seconds: int = 120
17
+ ) -> dict:
18
+ """Compara include_hidden=false y true sobre un workspace temporal."""
19
+ started = datetime.now(timezone.utc).isoformat()
20
+ repo = Path(root).expanduser().resolve()
21
+ template = Path(request_template).expanduser().resolve()
22
+ def set_dotted(obj: dict, dotted: str, value) -> None:
23
+ cur = obj
24
+ parts = dotted.split(".")
25
+ for part in parts[:-1]:
26
+ cur = cur.setdefault(part, {})
27
+ cur[parts[-1]] = value
28
+ def collect_paths(obj) -> set[str]:
29
+ found = set()
30
+ if isinstance(obj, dict):
31
+ for k, v in obj.items():
32
+ if k == "path" and isinstance(v, str): found.add(v)
33
+ found |= collect_paths(v)
34
+ elif isinstance(obj, list):
35
+ for v in obj: found |= collect_paths(v)
36
+ return found
37
+ cases = {}
38
+ with tempfile.TemporaryDirectory(prefix="auditagent-hidden-") as temp:
39
+ t = Path(temp); workspace = t / "workspace"
40
+ (workspace / ".hidden").mkdir(parents=True)
41
+ (workspace / "visible").mkdir()
42
+ (workspace / ".root-hidden.txt").write_text("x\n", encoding="utf-8")
43
+ (workspace / ".hidden" / "inside.txt").write_text("x\n", encoding="utf-8")
44
+ (workspace / "visible" / ".nested-hidden.txt").write_text("x\n", encoding="utf-8")
45
+ (workspace / "visible" / "keep.txt").write_text("x\n", encoding="utf-8")
46
+ for flag in (False, True):
47
+ req = json.loads(template.read_text(encoding="utf-8"))
48
+ req["root"] = str(workspace)
49
+ set_dotted(req, option_key, flag)
50
+ request, output = t / f"request-{flag}.json", t / f"output-{flag}.json"
51
+ request.write_text(json.dumps(req, ensure_ascii=False, indent=2), encoding="utf-8")
52
+ cmd = [x.format(request=str(request), output=str(output), root=str(workspace)) for x in shlex.split(command_template)]
53
+ env = os.environ.copy(); env.update({"PYTHONDONTWRITEBYTECODE": "1", "TMPDIR": temp})
54
+ cp = subprocess.run(cmd, cwd=repo, env=env, text=True, capture_output=True, timeout=timeout_seconds, check=False)
55
+ data = json.loads(output.read_text(encoding="utf-8")) if output.exists() else {}
56
+ cases[str(flag).lower()] = {"returncode": cp.returncode, "paths": sorted(collect_paths(data)), "stderr": cp.stderr[-2000:]}
57
+ hidden_false = [p for p in cases["false"]["paths"] if any(part.startswith(".") for part in Path(p).parts)]
58
+ hidden_true = [p for p in cases["true"]["paths"] if any(part.startswith(".") for part in Path(p).parts)]
59
+ findings = []
60
+ if hidden_false:
61
+ findings.append({"severity": "MEDIUM", "title": "Ocultos presentes con include_hidden=false", "detail": ", ".join(hidden_false)})
62
+ if not hidden_true:
63
+ findings.append({"severity": "LOW", "title": "No se observaron ocultos con include_hidden=true", "detail": "La opción puede no estar implementada o el formato no fue reconocido."})
64
+ return {
65
+ "schema_version": "1.0", "module": "hidden_policy",
66
+ "status": "FAIL" if hidden_false else ("WARN" if not hidden_true else "PASS"),
67
+ "confidence": "medium", "root": str(repo),
68
+ "summary": {"hidden_when_false": len(hidden_false), "hidden_when_true": len(hidden_true)},
69
+ "findings": findings, "evidence": [cases],
70
+ "limitations": ["La detección de rutas depende de campos llamados path en la salida JSON."],
71
+ "started_at": started, "finished_at": datetime.now(timezone.utc).isoformat(),
72
+ }
73
+
74
+ if __name__ == "__main__":
75
+ p = argparse.ArgumentParser(description="Prueba de archivos ocultos.")
76
+ p.add_argument("root")
77
+ p.add_argument("--request", required=True)
78
+ p.add_argument("--command", required=True)
79
+ p.add_argument("--option-key", default="options.include_hidden")
80
+ p.add_argument("--timeout", type=int, default=120)
81
+ p.add_argument("--json-output")
82
+ a = p.parse_args()
83
+ r = audit_hidden_policy(a.root, a.request, a.command, a.option_key, a.timeout)
84
+ s = json.dumps(r, ensure_ascii=False, indent=2)
85
+ Path(a.json_output).write_text(s + "\n", encoding="utf-8") if a.json_output else print(s)
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+ import argparse
3
+ import json
4
+ from pathlib import Path
5
+ from datetime import datetime, timezone
6
+
7
+ def audit_inventory(root: str | Path = ".", max_depth: int = 12) -> dict:
8
+ """Inventaría archivos sin modificarlos. Autodetecta la raíz del repositorio."""
9
+ started = datetime.now(timezone.utc).isoformat()
10
+ start = Path(root).expanduser().resolve()
11
+ start = start if start.is_dir() else start.parent
12
+ markers = (".git", ".colmena", "pyproject.toml", "package.json", "Cargo.toml")
13
+ probe = start
14
+ while probe.parent != probe and not any((probe / m).exists() for m in markers):
15
+ probe = probe.parent
16
+ repo = probe if any((probe / m).exists() for m in markers) else start
17
+ ignored_parts = {".git", ".venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache"}
18
+ files, dirs, symlinks, errors = [], [], [], []
19
+ for path in sorted(repo.rglob("*")):
20
+ try:
21
+ rel = path.relative_to(repo)
22
+ except ValueError:
23
+ continue
24
+ if len(rel.parts) > max_depth:
25
+ continue
26
+ if any(part in ignored_parts or part.endswith(".egg-info") for part in rel.parts):
27
+ continue
28
+ try:
29
+ if path.is_symlink():
30
+ symlinks.append({"path": rel.as_posix(), "target": str(path.readlink())})
31
+ elif path.is_dir():
32
+ dirs.append(rel.as_posix())
33
+ elif path.is_file():
34
+ st = path.stat()
35
+ files.append({
36
+ "path": rel.as_posix(),
37
+ "size_bytes": st.st_size,
38
+ "suffix": path.suffix.lower(),
39
+ "mtime_ns": st.st_mtime_ns,
40
+ })
41
+ except OSError as exc:
42
+ errors.append({"path": rel.as_posix(), "error": type(exc).__name__})
43
+ status = "PASS" if not errors else "WARN"
44
+ return {
45
+ "schema_version": "1.0",
46
+ "module": "inventory",
47
+ "status": status,
48
+ "confidence": "high",
49
+ "root": str(repo),
50
+ "summary": {
51
+ "files": len(files), "directories": len(dirs),
52
+ "symlinks": len(symlinks), "errors": len(errors),
53
+ "total_bytes": sum(x["size_bytes"] for x in files),
54
+ },
55
+ "findings": [
56
+ {"severity": "LOW", "title": "Errores de inventario", "detail": str(x), "path": x["path"]}
57
+ for x in errors
58
+ ],
59
+ "evidence": [{"files": files, "directories": dirs, "symlinks": symlinks}],
60
+ "limitations": ["No interpreta reglas .gitignore; excluye solo directorios técnicos conocidos."],
61
+ "started_at": started,
62
+ "finished_at": datetime.now(timezone.utc).isoformat(),
63
+ }
64
+
65
+ if __name__ == "__main__":
66
+ parser = argparse.ArgumentParser(description="Inventario independiente del repositorio.")
67
+ parser.add_argument("root", nargs="?", default=".")
68
+ parser.add_argument("--max-depth", type=int, default=12)
69
+ parser.add_argument("--json-output")
70
+ args = parser.parse_args()
71
+ result = audit_inventory(args.root, args.max_depth)
72
+ payload = json.dumps(result, ensure_ascii=False, indent=2)
73
+ if args.json_output:
74
+ Path(args.json_output).write_text(payload + "\n", encoding="utf-8")
75
+ else:
76
+ print(payload)
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+ import argparse
3
+ import json
4
+ import re
5
+ from pathlib import Path
6
+ from datetime import datetime, timezone
7
+
8
+ def audit_json_contracts(root: str | Path = ".") -> dict:
9
+ """Descubre JSON y esquemas, valida sintaxis y busca evidencia de aplicación real."""
10
+ started = datetime.now(timezone.utc).isoformat()
11
+ start = Path(root).expanduser().resolve()
12
+ start = start if start.is_dir() else start.parent
13
+ markers = (".git", ".colmena", "pyproject.toml")
14
+ probe = start
15
+ while probe.parent != probe and not any((probe / m).exists() for m in markers):
16
+ probe = probe.parent
17
+ repo = probe if any((probe / m).exists() for m in markers) else start
18
+ documents, schemas_found, invalid = [], [], []
19
+ for path in sorted(repo.rglob("*.json")):
20
+ rel = path.relative_to(repo)
21
+ if any(part in {".git", ".venv", "__pycache__"} or part.endswith(".egg-info") for part in rel.parts):
22
+ continue
23
+ try:
24
+ data = json.loads(path.read_text(encoding="utf-8"))
25
+ documents.append({"path": rel.as_posix(), "top_type": type(data).__name__})
26
+ if isinstance(data, dict) and ("$schema" in data or "$id" in data) and "properties" in data:
27
+ schemas_found.append(rel.as_posix())
28
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
29
+ invalid.append({"path": rel.as_posix(), "error": str(exc)})
30
+ source_text = ""
31
+ for path in repo.rglob("*.py"):
32
+ if any(part in {".git", ".venv", "__pycache__"} for part in path.relative_to(repo).parts):
33
+ continue
34
+ try:
35
+ source_text += "\n" + path.read_text(encoding="utf-8")
36
+ except (OSError, UnicodeError):
37
+ pass
38
+ validators = sorted(set(re.findall(r"\b(?:jsonschema|Draft20\d{2}Validator|validate)\b", source_text)))
39
+ findings = [
40
+ {"severity": "MEDIUM", "title": "JSON inválido", "detail": x["error"], "path": x["path"]}
41
+ for x in invalid
42
+ ]
43
+ if schemas_found and not validators:
44
+ findings.append({
45
+ "severity": "MEDIUM", "title": "Esquemas presentes sin evidencia clara de aplicación",
46
+ "detail": "Se encontraron contratos JSON, pero no referencias inequívocas a un validador JSON Schema."
47
+ })
48
+ return {
49
+ "schema_version": "1.0", "module": "json_contracts",
50
+ "status": "FAIL" if invalid else ("WARN" if findings else "PASS"),
51
+ "confidence": "medium", "root": str(repo),
52
+ "summary": {
53
+ "json_documents": len(documents), "schemas": len(schemas_found),
54
+ "invalid_json": len(invalid), "validator_tokens": validators
55
+ },
56
+ "findings": findings,
57
+ "evidence": [{"schemas": schemas_found, "documents": documents}],
58
+ "limitations": ["La búsqueda de aplicación del esquema es estática y heurística; no demuestra rutas dinámicas."],
59
+ "started_at": started,
60
+ "finished_at": datetime.now(timezone.utc).isoformat(),
61
+ }
62
+
63
+ if __name__ == "__main__":
64
+ p = argparse.ArgumentParser(description="Auditoría de contratos JSON.")
65
+ p.add_argument("root", nargs="?", default=".")
66
+ p.add_argument("--json-output")
67
+ a = p.parse_args()
68
+ r = audit_json_contracts(a.root)
69
+ s = json.dumps(r, ensure_ascii=False, indent=2)
70
+ Path(a.json_output).write_text(s + "\n", encoding="utf-8") if a.json_output else print(s)
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+ import argparse
3
+ import json
4
+ import os
5
+ import shlex
6
+ import subprocess
7
+ import tempfile
8
+ from pathlib import Path
9
+ from datetime import datetime, timezone
10
+
11
+ def audit_output_boundary(
12
+ root: str | Path,
13
+ request_template: str | Path,
14
+ command_template: str,
15
+ timeout_seconds: int = 120
16
+ ) -> dict:
17
+ """Prueba que varias rutas equivalentes dentro del workspace sean rechazadas."""
18
+ started = datetime.now(timezone.utc).isoformat()
19
+ repo = Path(root).expanduser().resolve()
20
+ template = Path(request_template).expanduser().resolve()
21
+ cases = []
22
+ with tempfile.TemporaryDirectory(prefix="auditagent-boundary-") as temp:
23
+ t = Path(temp)
24
+ workspace = t / "workspace"
25
+ (workspace / "subdir").mkdir(parents=True)
26
+ (workspace / "file.txt").write_text("data\n", encoding="utf-8")
27
+ req = json.loads(template.read_text(encoding="utf-8"))
28
+ req["root"] = str(workspace)
29
+ request = t / "request.json"
30
+ request.write_text(json.dumps(req, ensure_ascii=False, indent=2), encoding="utf-8")
31
+ outputs = [
32
+ workspace / "result.json",
33
+ workspace / "subdir" / "result.json",
34
+ workspace / ".." / "workspace" / "result-2.json",
35
+ workspace / "subdir" / ".." / "result-3.json",
36
+ ]
37
+ for output in outputs:
38
+ cmd = [x.format(request=str(request), output=str(output), root=str(workspace)) for x in shlex.split(command_template)]
39
+ env = os.environ.copy()
40
+ env.update({"PYTHONDONTWRITEBYTECODE": "1", "TMPDIR": temp})
41
+ cp = subprocess.run(cmd, cwd=repo, env=env, text=True, capture_output=True, timeout=timeout_seconds, check=False)
42
+ cases.append({
43
+ "output": str(output), "resolved_output": str(output.resolve()),
44
+ "returncode": cp.returncode, "created": output.exists(),
45
+ "stdout": cp.stdout[-2000:], "stderr": cp.stderr[-2000:]
46
+ })
47
+ bypasses = [x for x in cases if x["returncode"] == 0 or x["created"]]
48
+ return {
49
+ "schema_version": "1.0", "module": "output_boundary",
50
+ "status": "FAIL" if bypasses else "PASS",
51
+ "confidence": "high", "root": str(repo),
52
+ "summary": {"cases": len(cases), "bypasses": len(bypasses)},
53
+ "findings": [
54
+ {"severity": "HIGH", "title": "Salida interna no rechazada", "detail": x["resolved_output"]}
55
+ for x in bypasses
56
+ ],
57
+ "evidence": cases,
58
+ "limitations": ["No cubre carreras TOCTOU ni todos los esquemas de symlink de padres inexistentes."],
59
+ "started_at": started, "finished_at": datetime.now(timezone.utc).isoformat(),
60
+ }
61
+
62
+ if __name__ == "__main__":
63
+ p = argparse.ArgumentParser(description="Prueba de frontera de salida.")
64
+ p.add_argument("root")
65
+ p.add_argument("--request", required=True)
66
+ p.add_argument("--command", required=True)
67
+ p.add_argument("--timeout", type=int, default=120)
68
+ p.add_argument("--json-output")
69
+ a = p.parse_args()
70
+ r = audit_output_boundary(a.root, a.request, a.command, a.timeout)
71
+ s = json.dumps(r, ensure_ascii=False, indent=2)
72
+ Path(a.json_output).write_text(s + "\n", encoding="utf-8") if a.json_output else print(s)
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+ import argparse
3
+ import json
4
+ import os
5
+ import shlex
6
+ import subprocess
7
+ import tempfile
8
+ from pathlib import Path
9
+ from datetime import datetime, timezone
10
+
11
+ def audit_permission_handling(
12
+ root: str | Path,
13
+ request_template: str | Path,
14
+ command_template: str,
15
+ timeout_seconds: int = 120
16
+ ) -> dict:
17
+ """Prueba un archivo chmod 000 y registra si el entorno realmente impide leerlo."""
18
+ started = datetime.now(timezone.utc).isoformat()
19
+ repo = Path(root).expanduser().resolve()
20
+ template = Path(request_template).expanduser().resolve()
21
+ with tempfile.TemporaryDirectory(prefix="auditagent-permission-") as temp:
22
+ t = Path(temp); workspace = t / "workspace"; workspace.mkdir()
23
+ readable = workspace / "readable.txt"; unreadable = workspace / "unreadable.txt"
24
+ readable.write_text("ok\n", encoding="utf-8"); unreadable.write_text("secret\n", encoding="utf-8")
25
+ unreadable.chmod(0)
26
+ actually_unreadable = False
27
+ try:
28
+ unreadable.read_bytes()
29
+ except PermissionError:
30
+ actually_unreadable = True
31
+ req = json.loads(template.read_text(encoding="utf-8")); req["root"] = str(workspace)
32
+ request, output = t / "request.json", t / "output.json"
33
+ request.write_text(json.dumps(req, ensure_ascii=False, indent=2), encoding="utf-8")
34
+ cmd = [x.format(request=str(request), output=str(output), root=str(workspace)) for x in shlex.split(command_template)]
35
+ env = os.environ.copy(); env.update({"PYTHONDONTWRITEBYTECODE": "1", "TMPDIR": temp})
36
+ cp = subprocess.run(cmd, cwd=repo, env=env, text=True, capture_output=True, timeout=timeout_seconds, check=False)
37
+ unreadable.chmod(0o600)
38
+ output_valid = False
39
+ if output.exists():
40
+ try:
41
+ json.loads(output.read_text(encoding="utf-8")); output_valid = True
42
+ except Exception:
43
+ pass
44
+ limitations = []
45
+ if not actually_unreadable:
46
+ limitations.append("El usuario del proceso pudo leer chmod 000; el caso no demuestra manejo real de PermissionError.")
47
+ findings = []
48
+ if actually_unreadable and cp.returncode != 0:
49
+ findings.append({"severity": "MEDIUM", "title": "Fallo total ante archivo ilegible", "detail": cp.stderr[-2000:]})
50
+ if output.exists() and not output_valid:
51
+ findings.append({"severity": "HIGH", "title": "Salida inválida tras error de permisos", "detail": "El archivo de salida no es JSON válido."})
52
+ return {
53
+ "schema_version": "1.0", "module": "permission_handling",
54
+ "status": "FAIL" if findings else ("WARN" if not actually_unreadable else "PASS"),
55
+ "confidence": "low" if not actually_unreadable else "medium", "root": str(repo),
56
+ "summary": {"actually_unreadable": actually_unreadable, "returncode": cp.returncode, "output_valid_json": output_valid},
57
+ "findings": findings,
58
+ "evidence": [{"command": cmd, "stdout": cp.stdout[-3000:], "stderr": cp.stderr[-3000:]}],
59
+ "limitations": limitations,
60
+ "started_at": started, "finished_at": datetime.now(timezone.utc).isoformat(),
61
+ }
62
+
63
+ if __name__ == "__main__":
64
+ p = argparse.ArgumentParser(description="Prueba de errores de permisos.")
65
+ p.add_argument("root")
66
+ p.add_argument("--request", required=True)
67
+ p.add_argument("--command", required=True)
68
+ p.add_argument("--timeout", type=int, default=120)
69
+ p.add_argument("--json-output")
70
+ a = p.parse_args()
71
+ r = audit_permission_handling(a.root, a.request, a.command, a.timeout)
72
+ s = json.dumps(r, ensure_ascii=False, indent=2)
73
+ Path(a.json_output).write_text(s + "\n", encoding="utf-8") if a.json_output else print(s)
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+ import argparse
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import shlex
7
+ import subprocess
8
+ import tempfile
9
+ from pathlib import Path
10
+ from datetime import datetime, timezone
11
+
12
+ def audit_readonly_immutability(
13
+ root: str | Path,
14
+ request_template: str | Path,
15
+ command_template: str,
16
+ timeout_seconds: int = 120
17
+ ) -> dict:
18
+ """Ejecuta una herramienta contra un workspace temporal y compara snapshot antes/después."""
19
+ started = datetime.now(timezone.utc).isoformat()
20
+ repo = Path(root).expanduser().resolve()
21
+ template = Path(request_template).expanduser().resolve()
22
+ def snapshot(base: Path) -> dict:
23
+ out = {}
24
+ for p in sorted(base.rglob("*")):
25
+ if p.is_file() and not p.is_symlink():
26
+ raw = p.read_bytes()
27
+ st = p.stat()
28
+ out[p.relative_to(base).as_posix()] = {
29
+ "sha256": hashlib.sha256(raw).hexdigest(),
30
+ "size": st.st_size, "mtime_ns": st.st_mtime_ns, "mode": st.st_mode
31
+ }
32
+ return out
33
+ with tempfile.TemporaryDirectory(prefix="auditagent-ro-") as temp:
34
+ temp_path = Path(temp)
35
+ workspace = temp_path / "workspace"
36
+ workspace.mkdir()
37
+ (workspace / "nested").mkdir()
38
+ (workspace / "alpha.txt").write_text("alpha\n", encoding="utf-8")
39
+ (workspace / "nested" / "beta.md").write_text("beta\n", encoding="utf-8")
40
+ req = json.loads(template.read_text(encoding="utf-8"))
41
+ req["root"] = str(workspace)
42
+ request = temp_path / "request.json"
43
+ request.write_text(json.dumps(req, ensure_ascii=False, indent=2), encoding="utf-8")
44
+ output = temp_path / "output.json"
45
+ before = snapshot(workspace)
46
+ cmd = [x.format(request=str(request), output=str(output), root=str(workspace)) for x in shlex.split(command_template)]
47
+ env = os.environ.copy()
48
+ env.update({"PYTHONDONTWRITEBYTECODE": "1", "TMPDIR": temp})
49
+ cp = subprocess.run(cmd, cwd=repo, env=env, text=True, capture_output=True, timeout=timeout_seconds, check=False)
50
+ after = snapshot(workspace)
51
+ changed = {
52
+ key: {"before": before.get(key), "after": after.get(key)}
53
+ for key in sorted(set(before) | set(after))
54
+ if before.get(key) != after.get(key)
55
+ }
56
+ findings = []
57
+ if changed:
58
+ findings.append({"severity": "HIGH", "title": "Workspace modificado", "detail": f"{len(changed)} entradas cambiaron."})
59
+ if cp.returncode != 0:
60
+ findings.append({"severity": "MEDIUM", "title": "Comando auditado falló", "detail": cp.stderr[-2000:]})
61
+ return {
62
+ "schema_version": "1.0", "module": "readonly_immutability",
63
+ "status": "FAIL" if changed else ("WARN" if cp.returncode else "PASS"),
64
+ "confidence": "high", "root": str(repo),
65
+ "summary": {"returncode": cp.returncode, "changed_entries": len(changed), "output_created": output.exists()},
66
+ "findings": findings,
67
+ "evidence": [{"command": cmd, "changes": changed, "stdout": cp.stdout[-4000:], "stderr": cp.stderr[-4000:]}],
68
+ "limitations": ["Demuestra solo el caso temporal ejecutado; no impide carreras ni escrituras por rutas no cubiertas."],
69
+ "started_at": started, "finished_at": datetime.now(timezone.utc).isoformat(),
70
+ }
71
+
72
+ if __name__ == "__main__":
73
+ p = argparse.ArgumentParser(description="Prueba independiente de inmutabilidad.")
74
+ p.add_argument("root")
75
+ p.add_argument("--request", required=True)
76
+ p.add_argument("--command", required=True, help="Use {request}, {output} y {root}.")
77
+ p.add_argument("--timeout", type=int, default=120)
78
+ p.add_argument("--json-output")
79
+ a = p.parse_args()
80
+ r = audit_readonly_immutability(a.root, a.request, a.command, a.timeout)
81
+ s = json.dumps(r, ensure_ascii=False, indent=2)
82
+ Path(a.json_output).write_text(s + "\n", encoding="utf-8") if a.json_output else print(s)