convos-redact 0.8.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- convos_redact-0.8.1/PKG-INFO +17 -0
- convos_redact-0.8.1/README.md +5 -0
- convos_redact-0.8.1/pyproject.toml +28 -0
- convos_redact-0.8.1/setup.cfg +4 -0
- convos_redact-0.8.1/src/ai_convos_redact/__init__.py +124 -0
- convos_redact-0.8.1/src/convos_redact.egg-info/PKG-INFO +17 -0
- convos_redact-0.8.1/src/convos_redact.egg-info/SOURCES.txt +9 -0
- convos_redact-0.8.1/src/convos_redact.egg-info/dependency_links.txt +1 -0
- convos_redact-0.8.1/src/convos_redact.egg-info/entry_points.txt +5 -0
- convos_redact-0.8.1/src/convos_redact.egg-info/requires.txt +2 -0
- convos_redact-0.8.1/src/convos_redact.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: convos-redact
|
|
3
|
+
Version: 0.8.1
|
|
4
|
+
Summary: Local secret scanning and safe team projection policy for convos
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Documentation, https://github.com/RobertBiehl/convos/blob/master/docs/redact.md
|
|
7
|
+
Project-URL: Repository, https://github.com/RobertBiehl/convos
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: convos<0.9,>=0.8
|
|
11
|
+
Requires-Dist: typer>=0.12.0
|
|
12
|
+
|
|
13
|
+
# convos-redact
|
|
14
|
+
|
|
15
|
+
Local high-confidence secret scanning for conversation archives and the
|
|
16
|
+
mandatory pre-encryption policy used by `convos-remote` team projections.
|
|
17
|
+
See the [full documentation](https://github.com/RobertBiehl/convos/blob/master/docs/redact.md).
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "convos-redact"
|
|
3
|
+
version = "0.8.1"
|
|
4
|
+
description = "Local secret scanning and safe team projection policy for convos"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
|
+
dependencies = ["convos>=0.8,<0.9", "typer>=0.12.0"]
|
|
9
|
+
|
|
10
|
+
[project.urls]
|
|
11
|
+
Documentation = "https://github.com/RobertBiehl/convos/blob/master/docs/redact.md"
|
|
12
|
+
Repository = "https://github.com/RobertBiehl/convos"
|
|
13
|
+
|
|
14
|
+
[project.entry-points."convos.commands"]
|
|
15
|
+
redact = "ai_convos_redact:register"
|
|
16
|
+
|
|
17
|
+
[project.entry-points."convos.doctor"]
|
|
18
|
+
redact = "ai_convos_redact:doctor_status"
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["setuptools>=68"]
|
|
22
|
+
build-backend = "setuptools.build_meta"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["src"]
|
|
26
|
+
|
|
27
|
+
[tool.uv.sources]
|
|
28
|
+
convos = { workspace = true }
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Local secret scanning and mandatory safe team projections."""
|
|
2
|
+
import hashlib, json, os, re, sqlite3
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
PATTERNS=[
|
|
9
|
+
("private_key",re.compile(r"-----BEGIN ((?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY)-----.*?-----END \1-----",re.S),0),
|
|
10
|
+
("anthropic_key",re.compile(r"\bsk-ant-[A-Za-z0-9_-]{20,200}\b"),0),
|
|
11
|
+
("openai_key",re.compile(r"\bsk-(?!ant-)(?:proj-|svcacct-)?[A-Za-z0-9_-]{20,200}\b"),0),
|
|
12
|
+
("github_token",re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{20,255})\b"),0),
|
|
13
|
+
("gitlab_token",re.compile(r"\bglpat-[A-Za-z0-9_-]{20,200}\b"),0),
|
|
14
|
+
("aws_access_key",re.compile(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b"),0),
|
|
15
|
+
("google_api_key",re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b"),0),
|
|
16
|
+
("slack_token",re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,200}\b"),0),
|
|
17
|
+
("stripe_key",re.compile(r"\b(?:sk|rk)_live_[A-Za-z0-9]{16,200}\b"),0),
|
|
18
|
+
("pypi_token",re.compile(r"\bpypi-[A-Za-z0-9_-]{40,300}\b"),0),
|
|
19
|
+
("npm_token",re.compile(r"\bnpm_[A-Za-z0-9]{36,200}\b"),0),
|
|
20
|
+
("jwt",re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"),0),
|
|
21
|
+
("authorization",re.compile(r"(?i)\b(?:authorization\s*:\s*(?:bearer|basic)|bearer)\s+([A-Za-z0-9._~+/-]{12,}={0,2})"),1),
|
|
22
|
+
("credential_url",re.compile(r"\b[a-z][a-z0-9+.-]*://[^/\s:@]+:[^@\s/]+@",re.I),0),
|
|
23
|
+
("assigned_secret",re.compile(r"""(?i)\b(?:api[_-]?key|access[_-]?token|auth[_-]?token|session[_-]?token|client[_-]?secret|aws[_-]?secret[_-]?access[_-]?key|secret[_-]?access[_-]?key|password|passwd)\s*[:=]\s*["']?([A-Za-z0-9/+_=-]{8,})"""),1),
|
|
24
|
+
]
|
|
25
|
+
FIELDS={"conversations":("title","metadata"),"messages":("content","thinking","metadata"),"tool_calls":("input","output"),"attachments":("filename","url"),"artifacts":("title","content"),"file_edits":("file_path","content","old_content")}
|
|
26
|
+
PREFILTER=r"-----BEGIN|sk-(?:ant-|proj-|svcacct-)?|gh[pousr]_|github_pat_|glpat-|(?:AKIA|ASIA)[A-Z0-9]|AIza|xox[baprs]-|(?:sk|rk)_live_|pypi-|npm_|eyJ|authorization\s*:|bearer\s+|://[^/@\s]+:[^/@\s]+@|(?:api[_-]?key|access[_-]?token|auth[_-]?token|session[_-]?token|client[_-]?secret|aws[_-]?secret[_-]?access[_-]?key|secret[_-]?access[_-]?key|password|passwd)\s*[:=]"
|
|
27
|
+
SCHEMA="""CREATE TABLE IF NOT EXISTS findings(id TEXT PRIMARY KEY,workspace TEXT NOT NULL,entity TEXT NOT NULL,record_kind TEXT NOT NULL,secret_kind TEXT NOT NULL,path TEXT NOT NULL,line INT NOT NULL,first_seen TEXT NOT NULL,last_seen TEXT NOT NULL);"""
|
|
28
|
+
redact=typer.Typer(help="Find secrets locally and audit automatic team redactions")
|
|
29
|
+
|
|
30
|
+
def spans(text):
|
|
31
|
+
found=[]
|
|
32
|
+
for kind,pattern,group in PATTERNS:
|
|
33
|
+
for match in pattern.finditer(text):
|
|
34
|
+
start,end=match.span(group)
|
|
35
|
+
if not any(start<b and a<end for a,b,*_ in found): found.append((start,end,kind))
|
|
36
|
+
return sorted(found)
|
|
37
|
+
def scrub(text,path="$"):
|
|
38
|
+
matches=spans(text); found=[dict(kind=kind,path=path,line=text.count("\n",0,start)+1,start=start) for start,_,kind in matches]; safe=text
|
|
39
|
+
for start,end,kind in reversed(matches): safe=safe[:start]+f"[REDACTED:{kind}]"+safe[end:]
|
|
40
|
+
return safe,found
|
|
41
|
+
def inspect(value,path="$"):
|
|
42
|
+
if isinstance(value,str): return scrub(value,path)
|
|
43
|
+
if isinstance(value,dict):
|
|
44
|
+
rows=[(k,*inspect(v,f"{path}.{k}")) for k,v in value.items()]
|
|
45
|
+
return {k:safe for k,safe,_ in rows},[f for _,_,findings in rows for f in findings]
|
|
46
|
+
if isinstance(value,list):
|
|
47
|
+
rows=[inspect(v,f"{path}[{i}]") for i,v in enumerate(value)]
|
|
48
|
+
return [safe for safe,_ in rows],[f for _,findings in rows for f in findings]
|
|
49
|
+
return value,[]
|
|
50
|
+
def _root(root=None): return Path(root or os.environ.get("CONVOS_PROJECT_ROOT",Path.home()/".convos")).expanduser()
|
|
51
|
+
def _private_json(path,data):
|
|
52
|
+
from ai_convos.cli import durable_replace
|
|
53
|
+
if path.parent.is_symlink(): raise ValueError("Redaction state directory must not be a symlink")
|
|
54
|
+
path.parent.mkdir(parents=True,exist_ok=True); os.chmod(path.parent,0o700)
|
|
55
|
+
if path.is_symlink() or path.exists() and not path.is_file(): raise ValueError("Redaction state must be a regular non-symlink file")
|
|
56
|
+
tmp=path.with_name(f".{path.name}.{os.getpid()}"); tmp.touch(mode=0o600,exist_ok=False); tmp.write_text(json.dumps(data)); durable_replace(tmp,path)
|
|
57
|
+
def _audit(root,workspace,record,findings):
|
|
58
|
+
if not findings: return
|
|
59
|
+
path=_root(root)/"redact/audit.db"; path.parent.is_symlink() and (_ for _ in ()).throw(ValueError("Redaction audit directory must not be a symlink")); path.parent.mkdir(parents=True,exist_ok=True); os.chmod(path.parent,0o700)
|
|
60
|
+
if path.is_symlink() or path.exists() and not path.is_file(): raise ValueError("Redaction audit database must be a regular non-symlink file")
|
|
61
|
+
path.touch(mode=0o600,exist_ok=True); os.chmod(path,0o600); db=sqlite3.connect(path); db.executescript(SCHEMA); now=datetime.now(timezone.utc).isoformat()
|
|
62
|
+
for finding in findings:
|
|
63
|
+
ident="sec_"+hashlib.sha256(f"{workspace}\0{record['entity']}\0{finding['path']}\0{finding['line']}\0{finding['start']}\0{finding['kind']}".encode()).hexdigest()[:20]
|
|
64
|
+
db.execute("INSERT INTO findings VALUES (?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET last_seen=excluded.last_seen",(ident,workspace,record["entity"],record["kind"],finding["kind"],finding["path"],finding["line"],now,now))
|
|
65
|
+
db.commit(); db.close()
|
|
66
|
+
def protect(record,root=None,workspace="team"):
|
|
67
|
+
if record["kind"] in ("attachment.record","attachment.chunk"):
|
|
68
|
+
if record["kind"]=="attachment.chunk": _audit(root,workspace,record,[dict(kind="attachment_redacted",path="$.payload",line=1,start=0)]); return None
|
|
69
|
+
if record["payload"].get("state")=="deleted": return record
|
|
70
|
+
_audit(root,workspace,record,[dict(kind="attachment_redacted",path="$.payload",line=1,start=0)])
|
|
71
|
+
p=record["payload"]; row=dict(zip(p["columns"],p["row"])); row.update(filename="[REDACTED:attachment]",mime_type=None,size=None,path=None,url=None,**({"body_hash":None} if "body_hash" in row else {})); return dict(record,payload={**p,"row":[row[c] for c in p["columns"]]})
|
|
72
|
+
payload,findings=inspect(record["payload"],"$.payload"); _audit(root,workspace,record,findings)
|
|
73
|
+
return dict(record,payload=payload)
|
|
74
|
+
def protect_all(records,root=None,workspace="team"):
|
|
75
|
+
pairs=[(r,protect(r,root,workspace)) for r in records]; edits={r["payload"]["row"][0]:(r,s) for r,s in pairs if s and r["kind"]=="file_edit.record" and r!=s and r["payload"].get("state")!="deleted"}; facts={r["payload"]["id"]:r["payload"] for r,s in pairs if r["kind"]=="edit.observed" and r["payload"]["id"] in edits}; files={p["file"] for p in facts.values()}; repos={p["repository"] for p in facts.values()}; out=[]
|
|
76
|
+
for original,safe in pairs:
|
|
77
|
+
if not safe or safe["kind"]=="file.version" and safe["payload"]["file"] in files or safe["kind"]=="git.checkpoint" and safe["payload"]["repository"] in repos or safe["kind"]=="checkpoint.link" and safe["payload"]["edit"] in edits: continue
|
|
78
|
+
if safe["kind"]=="edit.observed" and safe["payload"]["id"] in edits:
|
|
79
|
+
record=edits[safe["payload"]["id"]][1]["payload"]; row=dict(zip(record["columns"],record["row"])); safe=dict(safe,payload={**safe["payload"],"old_content_hash":hashlib.sha256(row["old_content"].encode()).hexdigest() if row["old_content"] is not None else None,"new_content_hash":hashlib.sha256((row["content"] or "").encode()).hexdigest()})
|
|
80
|
+
if safe["kind"]=="repository.observed" and safe["payload"]["id"] in repos: safe=dict(safe,payload={**safe["payload"],"lineage":None,"roots":[],"head":None})
|
|
81
|
+
out.append(safe)
|
|
82
|
+
return out
|
|
83
|
+
def scan_data(cache=False):
|
|
84
|
+
from ai_convos.cli import DB_PATH,get_db
|
|
85
|
+
before=(str(DB_PATH.resolve()),DB_PATH.stat().st_mtime_ns,DB_PATH.stat().st_size); saved=_root()/"redact/scan.json"
|
|
86
|
+
if cache and (saved.parent.is_symlink() or saved.is_symlink()): raise ValueError("Redaction scan cache must not use a symlink")
|
|
87
|
+
if cache and saved.is_file() and not saved.is_symlink():
|
|
88
|
+
os.chmod(saved,0o600)
|
|
89
|
+
try:
|
|
90
|
+
old=json.loads(saved.read_text())
|
|
91
|
+
if old["key"]==list(before): return dict(old["data"],cached=True)
|
|
92
|
+
except (OSError,ValueError,KeyError,TypeError): pass
|
|
93
|
+
db=get_db(read_only=True); findings=[]
|
|
94
|
+
for table,fields in FIELDS.items():
|
|
95
|
+
for field in fields:
|
|
96
|
+
cursor=db.execute(f"SELECT id,CAST({field} AS VARCHAR) FROM {table} WHERE {field} IS NOT NULL AND regexp_matches(CAST({field} AS VARCHAR), ?, 'i')",[PREFILTER])
|
|
97
|
+
for rows in iter(lambda:cursor.fetchmany(64),[]):
|
|
98
|
+
for row_id,value in rows:
|
|
99
|
+
_,seen=inspect(value,f"$.{field}"); findings += [dict(f,table=table,row_id=row_id,field=field) for f in seen]
|
|
100
|
+
db.close(); data=dict(status="clean" if not findings else "secrets_found",total=len(findings),by_kind={kind:sum(f["kind"]==kind for f in findings) for kind in sorted({f["kind"] for f in findings})},findings=findings,cached=False)
|
|
101
|
+
if cache and (str(DB_PATH.resolve()),DB_PATH.stat().st_mtime_ns,DB_PATH.stat().st_size)==before: _private_json(saved,{"key":before,"data":data})
|
|
102
|
+
return data
|
|
103
|
+
def audit_data(root=None):
|
|
104
|
+
path=_root(root)/"redact/audit.db"
|
|
105
|
+
if path.parent.is_symlink(): raise ValueError("Redaction audit database must be a regular non-symlink file")
|
|
106
|
+
if not path.exists(): return dict(status="clean",total=0,by_kind={},findings=[])
|
|
107
|
+
if path.is_symlink() or not path.is_file(): raise ValueError("Redaction audit database must be a regular non-symlink file")
|
|
108
|
+
db=sqlite3.connect(path); rows=db.execute("SELECT id,workspace,entity,record_kind,secret_kind,path,line,first_seen,last_seen FROM findings ORDER BY last_seen DESC").fetchall(); db.close(); keys=("id","workspace","entity","record_kind","kind","path","line","first_seen","last_seen")
|
|
109
|
+
return dict(status="clean" if not rows else "redacted",total=len(rows),by_kind={kind:sum(r[4]==kind for r in rows) for kind in sorted({r[4] for r in rows})},findings=[dict(zip(keys,row)) for row in rows])
|
|
110
|
+
def emit(data,fmt):
|
|
111
|
+
if fmt=="json": typer.echo(json.dumps(data)); return
|
|
112
|
+
typer.echo(f"{data['status']}: {data['total']} finding{'s' if data['total']!=1 else ''}")
|
|
113
|
+
[typer.echo(f"- {r['kind']}: {r.get('table',r.get('record_kind'))}:{r.get('row_id',r.get('entity'))} {r['path']}:{r['line']}") for r in data["findings"]]
|
|
114
|
+
@redact.command("scan")
|
|
115
|
+
def scan_cmd(fmt:str=typer.Option("text","-f","--format"),fresh:bool=typer.Option(False,"--fresh",help="Ignore an exact unchanged-database scan cache.")):
|
|
116
|
+
if fmt not in ("text","json"): raise typer.BadParameter("must be text or json","--format")
|
|
117
|
+
emit(scan_data(not fresh),fmt)
|
|
118
|
+
@redact.command("status")
|
|
119
|
+
def status_cmd(fmt:str=typer.Option("text","-f","--format")):
|
|
120
|
+
if fmt not in ("text","json"): raise typer.BadParameter("must be text or json","--format")
|
|
121
|
+
emit(audit_data(),fmt)
|
|
122
|
+
def doctor_status():
|
|
123
|
+
data=audit_data(); return f"redact: {data['total']} automatic team redaction{'s' if data['total']!=1 else ''} recorded"
|
|
124
|
+
def register(app): app.add_typer(redact,name="redact")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: convos-redact
|
|
3
|
+
Version: 0.8.1
|
|
4
|
+
Summary: Local secret scanning and safe team projection policy for convos
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Documentation, https://github.com/RobertBiehl/convos/blob/master/docs/redact.md
|
|
7
|
+
Project-URL: Repository, https://github.com/RobertBiehl/convos
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: convos<0.9,>=0.8
|
|
11
|
+
Requires-Dist: typer>=0.12.0
|
|
12
|
+
|
|
13
|
+
# convos-redact
|
|
14
|
+
|
|
15
|
+
Local high-confidence secret scanning for conversation archives and the
|
|
16
|
+
mandatory pre-encryption policy used by `convos-remote` team projections.
|
|
17
|
+
See the [full documentation](https://github.com/RobertBiehl/convos/blob/master/docs/redact.md).
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/ai_convos_redact/__init__.py
|
|
4
|
+
src/convos_redact.egg-info/PKG-INFO
|
|
5
|
+
src/convos_redact.egg-info/SOURCES.txt
|
|
6
|
+
src/convos_redact.egg-info/dependency_links.txt
|
|
7
|
+
src/convos_redact.egg-info/entry_points.txt
|
|
8
|
+
src/convos_redact.egg-info/requires.txt
|
|
9
|
+
src/convos_redact.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ai_convos_redact
|