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.
- auditagent_toolkit-0.2.0.dist-info/METADATA +453 -0
- auditagent_toolkit-0.2.0.dist-info/RECORD +27 -0
- auditagent_toolkit-0.2.0.dist-info/WHEEL +4 -0
- auditagent_toolkit-0.2.0.dist-info/entry_points.txt +2 -0
- auditagent_toolkit-0.2.0.dist-info/licenses/LICENSE +201 -0
- auditagent_toolkit-0.2.0.dist-info/licenses/NOTICE +8 -0
- auditagent_tools/__init__.py +15 -0
- auditagent_tools/assemble_report.py +54 -0
- auditagent_tools/atomic_output.py +73 -0
- auditagent_tools/binary_handling.py +71 -0
- auditagent_tools/cli.py +171 -0
- auditagent_tools/determinism.py +66 -0
- auditagent_tools/exclusion_policy.py +84 -0
- auditagent_tools/git_state.py +78 -0
- auditagent_tools/hidden_policy.py +85 -0
- auditagent_tools/inventory.py +76 -0
- auditagent_tools/json_contracts.py +70 -0
- auditagent_tools/output_boundary.py +72 -0
- auditagent_tools/permission_handling.py +73 -0
- auditagent_tools/readonly_immutability.py +82 -0
- auditagent_tools/result_coherence.py +67 -0
- auditagent_tools/score_module.py +46 -0
- auditagent_tools/sensitive_data.py +73 -0
- auditagent_tools/size_limits.py +76 -0
- auditagent_tools/symlink_policy.py +87 -0
- auditagent_tools/test_runner.py +70 -0
- auditagent_tools/write_surface.py +80 -0
|
@@ -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)
|