firstwatch 0.1.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.
- firstwatch/__init__.py +51 -0
- firstwatch/__main__.py +3 -0
- firstwatch/_rules.py +37 -0
- firstwatch/capture.py +262 -0
- firstwatch/cli.py +116 -0
- firstwatch/redact.py +69 -0
- firstwatch/rules.json +289 -0
- firstwatch/scan.py +240 -0
- firstwatch-0.1.0.dist-info/METADATA +65 -0
- firstwatch-0.1.0.dist-info/RECORD +13 -0
- firstwatch-0.1.0.dist-info/WHEEL +4 -0
- firstwatch-0.1.0.dist-info/entry_points.txt +2 -0
- firstwatch-0.1.0.dist-info/licenses/LICENSE +21 -0
firstwatch/__init__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""firstwatch: first line of defence for AI agents.
|
|
2
|
+
|
|
3
|
+
from firstwatch import scan, watch
|
|
4
|
+
|
|
5
|
+
scan(page_html) # {"level": "clean"|"note"|"stop", "findings": [...]}
|
|
6
|
+
watch(page_html, url=url) # scans, saves a capture on a stop, returns the result + "saved"
|
|
7
|
+
|
|
8
|
+
Same rules and capture format as the Node package (https://github.com/dave8172/firstwatch).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from ._rules import RULES_VERSION
|
|
12
|
+
from .capture import SPEC, VERSION, build, capture_dir, to_otlp, validate, write_capture, write_note
|
|
13
|
+
from .redact import redact
|
|
14
|
+
from .scan import scan
|
|
15
|
+
|
|
16
|
+
__version__ = VERSION
|
|
17
|
+
__all__ = ["scan", "watch", "build", "validate", "write_capture", "write_note", "to_otlp", "redact", "capture_dir", "SPEC", "RULES_VERSION", "__version__"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def watch(text, *, url=None, file=None, tool=None, kind=None, task=None, steps=None, session_id=None, agent_name=None, dir=None, raw=None):
|
|
21
|
+
"""Scan one piece of outside content and record what it finds.
|
|
22
|
+
|
|
23
|
+
A stop is saved as a capture folder, a note as one line in notes.jsonl.
|
|
24
|
+
Returns the scan result plus "saved" (the folder or notes file, or None when clean).
|
|
25
|
+
Your code decides what to do next; on a stop, don't pass the content on as instructions.
|
|
26
|
+
"""
|
|
27
|
+
result = scan(text)
|
|
28
|
+
result["saved"] = None
|
|
29
|
+
if result["level"] == "clean":
|
|
30
|
+
return result
|
|
31
|
+
from .capture import primary
|
|
32
|
+
|
|
33
|
+
top = primary(result["findings"])
|
|
34
|
+
repro = [f"Fetch {url} as raw HTML (no JavaScript)." if url else f"Open {file}." if file else "Feed the same content to the agent."]
|
|
35
|
+
repro.append(f'Find the text: "{top["excerpt"][:160]}"')
|
|
36
|
+
rec = build({
|
|
37
|
+
"level": result["level"],
|
|
38
|
+
"findings": result["findings"],
|
|
39
|
+
"source": {"kind": kind or ("web" if url else "file" if file else "tool" if tool else "other"), "url": url, "file": file, "tool": tool, "where": top["where"]},
|
|
40
|
+
"payload": {"text": top["excerpt"], "hidden": top["hidden"], "hidden_how": top["hidden_how"]},
|
|
41
|
+
"caught_by": "scanner",
|
|
42
|
+
"outcome": "blocked" if result["level"] == "stop" else "unknown",
|
|
43
|
+
"trace": {"task": task, "steps": steps or []},
|
|
44
|
+
"repro": repro,
|
|
45
|
+
"context": {"session_id": session_id, "agent_name": agent_name},
|
|
46
|
+
})
|
|
47
|
+
if result["level"] == "stop":
|
|
48
|
+
result["saved"] = write_capture(rec, dir=dir, raw=raw if raw is not None else text, raw_kind="html" if "<" in (raw or text) else "text")
|
|
49
|
+
else:
|
|
50
|
+
result["saved"] = write_note(rec, dir=dir)
|
|
51
|
+
return result
|
firstwatch/__main__.py
ADDED
firstwatch/_rules.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Loads the shared detection rules (rules.json, the same file the Node package uses)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from importlib import resources
|
|
6
|
+
|
|
7
|
+
_data = json.loads(resources.files("firstwatch").joinpath("rules.json").read_text(encoding="utf-8"))
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _rx(p):
|
|
11
|
+
# re.ASCII keeps \w and \b the same as JavaScript regexes without the u flag.
|
|
12
|
+
flags = re.ASCII
|
|
13
|
+
if "i" in p["flags"]:
|
|
14
|
+
flags |= re.IGNORECASE
|
|
15
|
+
return re.compile(p["source"], flags)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
RULES_VERSION = _data["version"]
|
|
19
|
+
ATLAS = _data["atlas"]
|
|
20
|
+
OWASP = _data["owasp"]
|
|
21
|
+
AGENT_RE = _rx(_data["agent"])
|
|
22
|
+
HIDDEN_STYLE = [(_rx(h), h["label"]) for h in _data["hidden_style"]]
|
|
23
|
+
DISCUSSION_RE = _rx(_data["discussion"])
|
|
24
|
+
DISCUSSION_MIN = _data["discussion"]["min"]
|
|
25
|
+
PRIORITY = _data["priority"]
|
|
26
|
+
BUILTIN = {b["id"]: b for b in _data["builtin"]}
|
|
27
|
+
|
|
28
|
+
RULES = []
|
|
29
|
+
for _r in _data["rules"]:
|
|
30
|
+
_rule = dict(_r)
|
|
31
|
+
if "pattern" in _r:
|
|
32
|
+
_rule["re"] = _rx(_r["pattern"])
|
|
33
|
+
else:
|
|
34
|
+
_rule["agent_re"] = _rx(_r["agent"])
|
|
35
|
+
_rule["imperative_re"] = _rx(_r["imperative"])
|
|
36
|
+
_rule["danger_re"] = _rx(_r["danger"])
|
|
37
|
+
RULES.append(_rule)
|
firstwatch/capture.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Build, check and write capture records (format: SPEC.md, firstwatch/0.1). Mirrors src/capture.js."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import secrets
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
from ._rules import ATLAS, OWASP, PRIORITY
|
|
12
|
+
from .redact import redact, redact_deep
|
|
13
|
+
|
|
14
|
+
SPEC = "firstwatch/0.1"
|
|
15
|
+
VERSION = "0.1.0"
|
|
16
|
+
LEVELS = ["stop", "note"]
|
|
17
|
+
OUTCOMES = ["blocked", "followed", "partial", "unknown"]
|
|
18
|
+
CAUGHT_BY = ["hook", "agent", "scanner", "owner"]
|
|
19
|
+
KINDS = ["web", "file", "command", "tool", "search", "message", "email", "other"]
|
|
20
|
+
MAX_EVIDENCE = 2_000_000
|
|
21
|
+
_TIME = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d(:\d\d(\.\d+)?)?(Z|[+-]\d\d:\d\d)$")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def primary(findings):
|
|
25
|
+
"""Most serious first: what the attack does, then how it hides."""
|
|
26
|
+
def rank(f):
|
|
27
|
+
i = PRIORITY.index(f["rule"]) + 1 if f.get("rule") in PRIORITY else 50
|
|
28
|
+
return (0 if f.get("level") == "stop" else 100) + i
|
|
29
|
+
return sorted(findings, key=rank)[0] if findings else None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def capture_dir(cwd=None):
|
|
33
|
+
return Path(os.environ.get("FIRSTWATCH_DIR") or Path(cwd or os.getcwd()) / "security-captures").resolve()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def iso_local(d=None):
|
|
37
|
+
"""ISO 8601 in local time with the UTC offset, e.g. 2026-10-29T14:05:00.123+05:30."""
|
|
38
|
+
return (d or datetime.now()).astimezone().isoformat(timespec="milliseconds")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _slug(s):
|
|
42
|
+
s = re.sub(r"^www\.", "", str(s or "unknown").lower())
|
|
43
|
+
return re.sub(r"[^a-z0-9]+", "-", s).strip("-")[:40] or "unknown"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _host(url):
|
|
47
|
+
try:
|
|
48
|
+
return urlparse(url).hostname if url else None
|
|
49
|
+
except ValueError:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def validate(rec):
|
|
54
|
+
"""Every problem at once, not the first. Returns a list of strings (empty = valid)."""
|
|
55
|
+
if not isinstance(rec, dict):
|
|
56
|
+
return ["record must be an object"]
|
|
57
|
+
p = []
|
|
58
|
+
|
|
59
|
+
def need(cond, msg):
|
|
60
|
+
if not cond:
|
|
61
|
+
p.append(msg)
|
|
62
|
+
|
|
63
|
+
src, pay = rec.get("source") or {}, rec.get("payload") or {}
|
|
64
|
+
need(rec.get("spec") == SPEC, f'spec must be "{SPEC}"')
|
|
65
|
+
need(isinstance(rec.get("id"), str) and rec["id"], "id is required")
|
|
66
|
+
need(isinstance(rec.get("time"), str) and _TIME.match(rec["time"]), "time must be ISO 8601 with a UTC offset")
|
|
67
|
+
need(rec.get("level") in LEVELS, f"level must be one of {', '.join(LEVELS)}")
|
|
68
|
+
need(isinstance(rec.get("summary"), str) and rec["summary"].strip(), "summary is required")
|
|
69
|
+
need(src.get("kind") in KINDS, f"source.kind must be one of {', '.join(KINDS)}")
|
|
70
|
+
need(isinstance(pay.get("text"), str) and pay["text"], "payload.text (the exact text) is required")
|
|
71
|
+
need(isinstance(pay.get("hidden"), bool), "payload.hidden must be true or false")
|
|
72
|
+
need(isinstance(rec.get("findings"), list), "findings must be a list")
|
|
73
|
+
need(rec.get("outcome") in OUTCOMES, f"outcome must be one of {', '.join(OUTCOMES)}")
|
|
74
|
+
need(rec.get("caught_by") in CAUGHT_BY, f"caught_by must be one of {', '.join(CAUGHT_BY)}")
|
|
75
|
+
need(isinstance(rec.get("repro"), list), "repro must be a list of steps")
|
|
76
|
+
return p
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def build(data):
|
|
80
|
+
"""Turn partial input (from a hook, an agent or your code) into a full record. Redacts as it goes."""
|
|
81
|
+
now = datetime.now(timezone.utc)
|
|
82
|
+
findings = data.get("findings") or []
|
|
83
|
+
top = primary(findings) or {}
|
|
84
|
+
source = data.get("source") or {}
|
|
85
|
+
payload_in = data.get("payload") or {}
|
|
86
|
+
url = source.get("url")
|
|
87
|
+
text, n1 = redact(payload_in.get("text") or top.get("excerpt") or "", keep_emails=True)
|
|
88
|
+
trace, n2 = redact_deep(data.get("trace") or {})
|
|
89
|
+
intent = data.get("intent") or top.get("what") or ""
|
|
90
|
+
hidden = payload_in.get("hidden", bool(top.get("hidden")))
|
|
91
|
+
where = f" on {_host(url)}" if url else (f" in {os.path.basename(source['file'])}" if source.get("file") else "")
|
|
92
|
+
rec = {
|
|
93
|
+
"spec": SPEC,
|
|
94
|
+
"id": f"fw_{now.strftime('%Y%m%dT%H%M%S')}_{secrets.token_hex(3)}",
|
|
95
|
+
"time": data.get("time") or iso_local(),
|
|
96
|
+
"level": data.get("level") or ("stop" if any(f.get("level") == "stop" for f in findings) else "note"),
|
|
97
|
+
"summary": data.get("summary") or f"{'Hidden text' if hidden else 'Text'}{where}: {intent or 'targets AI agents'}",
|
|
98
|
+
"source": {
|
|
99
|
+
"kind": source.get("kind") or ("web" if url else "other"),
|
|
100
|
+
"url": url,
|
|
101
|
+
"tool": source.get("tool"),
|
|
102
|
+
"where": source.get("where") or top.get("where"),
|
|
103
|
+
"file": source.get("file"),
|
|
104
|
+
},
|
|
105
|
+
"payload": {"text": text, "hidden": hidden, "hidden_how": payload_in.get("hidden_how", top.get("hidden_how", []))},
|
|
106
|
+
"findings": findings,
|
|
107
|
+
"intent": intent,
|
|
108
|
+
"outcome": data.get("outcome") or "unknown",
|
|
109
|
+
"caught_by": data.get("caught_by") or "agent",
|
|
110
|
+
"trace": trace,
|
|
111
|
+
"repro": data.get("repro") or [],
|
|
112
|
+
"evidence": [],
|
|
113
|
+
"redactions": n1 + n2,
|
|
114
|
+
}
|
|
115
|
+
if data.get("notes"):
|
|
116
|
+
rec["notes"] = redact(data["notes"])[0]
|
|
117
|
+
rec["attributes"] = _attributes(rec, data.get("context") or {})
|
|
118
|
+
return rec
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _attributes(rec, ctx):
|
|
122
|
+
"""Flat attributes: OpenTelemetry names where a convention exists, firstwatch.* where none does."""
|
|
123
|
+
def uniq(xs):
|
|
124
|
+
return list(dict.fromkeys(xs))
|
|
125
|
+
|
|
126
|
+
a = {
|
|
127
|
+
"event.name": "firstwatch.capture",
|
|
128
|
+
"firstwatch.spec": SPEC,
|
|
129
|
+
"firstwatch.capture.id": rec["id"],
|
|
130
|
+
"firstwatch.level": rec["level"],
|
|
131
|
+
"firstwatch.outcome": rec["outcome"],
|
|
132
|
+
"firstwatch.caught_by": rec["caught_by"],
|
|
133
|
+
"firstwatch.hidden": rec["payload"]["hidden"],
|
|
134
|
+
"firstwatch.rules": uniq(f["rule"] for f in rec["findings"]),
|
|
135
|
+
"firstwatch.atlas.techniques": uniq(t for f in rec["findings"] for t in f.get("atlas", [])),
|
|
136
|
+
"firstwatch.owasp.ids": uniq(t for f in rec["findings"] for t in f.get("owasp", [])),
|
|
137
|
+
}
|
|
138
|
+
if rec["source"]["url"]:
|
|
139
|
+
a["url.full"] = rec["source"]["url"]
|
|
140
|
+
if _host(rec["source"]["url"]):
|
|
141
|
+
a["server.address"] = _host(rec["source"]["url"])
|
|
142
|
+
if ctx.get("session_id"):
|
|
143
|
+
a["gen_ai.conversation.id"] = ctx["session_id"]
|
|
144
|
+
if rec["source"]["tool"]:
|
|
145
|
+
a["gen_ai.operation.name"] = "execute_tool"
|
|
146
|
+
a["gen_ai.tool.name"] = rec["source"]["tool"]
|
|
147
|
+
if ctx.get("tool_use_id"):
|
|
148
|
+
a["gen_ai.tool.call.id"] = ctx["tool_use_id"]
|
|
149
|
+
if ctx.get("agent_name"):
|
|
150
|
+
a["gen_ai.agent.name"] = ctx["agent_name"]
|
|
151
|
+
return a
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _readme(rec):
|
|
155
|
+
s, p = rec["source"], rec["payload"]
|
|
156
|
+
via = f" (via {s['tool']})" if s.get("tool") else ""
|
|
157
|
+
lines = [
|
|
158
|
+
f"# {rec['level'].upper()}: {rec['summary']}",
|
|
159
|
+
"",
|
|
160
|
+
f"- **Time:** {rec['time']}",
|
|
161
|
+
f"- **Source:** {s.get('url') or s.get('file') or s['kind']}{via}",
|
|
162
|
+
f"- **Where:** {s.get('where') or 'not recorded'}",
|
|
163
|
+
f"- **Hidden from people:** {('yes (' + (', '.join(p['hidden_how']) or 'how not recorded') + ')') if p['hidden'] else 'no'}",
|
|
164
|
+
f"- **What it tried:** {rec['intent'] or 'not recorded'}",
|
|
165
|
+
f"- **Outcome:** {rec['outcome']} · **caught by:** {rec['caught_by']}",
|
|
166
|
+
"",
|
|
167
|
+
"## Exact text",
|
|
168
|
+
"",
|
|
169
|
+
"```text",
|
|
170
|
+
p["text"].replace("```", "'''"),
|
|
171
|
+
"```",
|
|
172
|
+
"",
|
|
173
|
+
]
|
|
174
|
+
if rec["findings"]:
|
|
175
|
+
lines += ["## Findings", "", "| Rule | Level | Where | ATLAS | OWASP |", "|---|---|---|---|---|"]
|
|
176
|
+
for f in rec["findings"]:
|
|
177
|
+
atlas = "; ".join(f"{t} {ATLAS.get(t, '')}".strip() for t in f.get("atlas", []))
|
|
178
|
+
owasp = "; ".join(f"{t} {OWASP.get(t, '')}".strip() for t in f.get("owasp", []))
|
|
179
|
+
lines.append(f"| {f['rule']} | {f['level']} | {f.get('where') or ''} | {atlas} | {owasp} |")
|
|
180
|
+
lines.append("")
|
|
181
|
+
if rec["repro"]:
|
|
182
|
+
lines += ["## Reproduce", ""] + [f"{i + 1}. {x}" for i, x in enumerate(rec["repro"])] + [""]
|
|
183
|
+
steps = (rec.get("trace") or {}).get("steps") or []
|
|
184
|
+
if steps:
|
|
185
|
+
lines += ["## Trace", ""]
|
|
186
|
+
if rec["trace"].get("task"):
|
|
187
|
+
lines += [f"Task: {rec['trace']['task']}", ""]
|
|
188
|
+
lines += [f"{i + 1}. `{x.get('tool')}` {x.get('summary') or ''}".strip() for i, x in enumerate(steps)] + [""]
|
|
189
|
+
if rec["evidence"]:
|
|
190
|
+
lines += ["## Evidence", ""] + [f"- {e}" for e in rec["evidence"]] + [""]
|
|
191
|
+
if rec.get("notes"):
|
|
192
|
+
lines += ["## Notes", "", rec["notes"], ""]
|
|
193
|
+
lines += ["---", "", f"Recorded by firstwatch {VERSION}. {rec['redactions']} value(s) masked before saving. Raw page source is stored as .txt so it never opens in a browser by mistake. Keep this folder private unless you choose to share it.", ""]
|
|
194
|
+
return "\n".join(lines)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _append(path, obj):
|
|
198
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
199
|
+
f.write(json.dumps(obj, ensure_ascii=False) + "\n")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def write_capture(rec, dir=None, raw=None, raw_kind="html"):
|
|
203
|
+
"""Write a stop capture as a folder. Returns the folder path."""
|
|
204
|
+
problems = validate(rec)
|
|
205
|
+
if problems:
|
|
206
|
+
raise ValueError("invalid capture:\n- " + "\n- ".join(problems))
|
|
207
|
+
d = Path(dir) if dir else capture_dir()
|
|
208
|
+
where = _slug(_host(rec["source"].get("url")) or (rec["source"].get("file") and os.path.basename(rec["source"]["file"])) or rec["source"].get("tool") or rec["source"]["kind"])
|
|
209
|
+
name = _slug((primary(rec["findings"]) or {}).get("rule") or "capture")
|
|
210
|
+
folder = d / f"{rec['time'][:10]}-{where}-{name}"
|
|
211
|
+
n = 2
|
|
212
|
+
while folder.exists():
|
|
213
|
+
folder = d / f"{rec['time'][:10]}-{where}-{name}-{n}"
|
|
214
|
+
n += 1
|
|
215
|
+
folder.mkdir(parents=True)
|
|
216
|
+
if raw:
|
|
217
|
+
file = "raw.html.txt" if raw_kind == "html" else "raw.txt"
|
|
218
|
+
masked, count = redact(raw[:MAX_EVIDENCE], keep_emails=True)
|
|
219
|
+
(folder / file).write_text(masked, encoding="utf-8")
|
|
220
|
+
rec["evidence"].append(file)
|
|
221
|
+
rec["redactions"] += count
|
|
222
|
+
(folder / "capture.json").write_text(json.dumps(rec, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
223
|
+
(folder / "README.md").write_text(_readme(rec), encoding="utf-8")
|
|
224
|
+
_append(d / "index.jsonl", {"time": rec["time"], "level": rec["level"], "id": rec["id"], "folder": str(folder), "summary": rec["summary"]})
|
|
225
|
+
return str(folder)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def write_note(rec, dir=None):
|
|
229
|
+
"""A note is one line in notes.jsonl, not a folder."""
|
|
230
|
+
d = Path(dir) if dir else capture_dir()
|
|
231
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
232
|
+
_append(d / "notes.jsonl", {"time": rec["time"], "level": "note", "id": rec["id"], "source": rec["source"], "rules": [f["rule"] for f in rec["findings"]], "excerpt": rec["payload"]["text"][:300], "hidden_how": rec["payload"]["hidden_how"]})
|
|
233
|
+
return str(d / "notes.jsonl")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def to_otlp(rec):
|
|
237
|
+
"""One OTLP/JSON log record, ready to POST to a collector's /v1/logs. No network here."""
|
|
238
|
+
def value(v):
|
|
239
|
+
if isinstance(v, list):
|
|
240
|
+
return {"arrayValue": {"values": [value(x) for x in v]}}
|
|
241
|
+
if isinstance(v, bool):
|
|
242
|
+
return {"boolValue": v}
|
|
243
|
+
if isinstance(v, (int, float)):
|
|
244
|
+
return {"doubleValue": v}
|
|
245
|
+
return {"stringValue": str(v)}
|
|
246
|
+
|
|
247
|
+
t = datetime.fromisoformat(rec["time"].replace("Z", "+00:00"))
|
|
248
|
+
stop = rec["level"] == "stop"
|
|
249
|
+
return {"resourceLogs": [{
|
|
250
|
+
"resource": {"attributes": [{"key": "service.name", "value": {"stringValue": "firstwatch"}}]},
|
|
251
|
+
"scopeLogs": [{
|
|
252
|
+
"scope": {"name": "firstwatch", "version": VERSION},
|
|
253
|
+
"logRecords": [{
|
|
254
|
+
"timeUnixNano": str(int(t.timestamp() * 1000) * 1_000_000),
|
|
255
|
+
"severityNumber": 13 if stop else 9,
|
|
256
|
+
"severityText": "WARN" if stop else "INFO",
|
|
257
|
+
"eventName": "firstwatch.capture",
|
|
258
|
+
"body": {"stringValue": rec["summary"]},
|
|
259
|
+
"attributes": [{"key": k, "value": value(v)} for k, v in (rec.get("attributes") or {}).items()],
|
|
260
|
+
}],
|
|
261
|
+
}],
|
|
262
|
+
}]}
|
firstwatch/cli.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""firstwatch command line (Python). Same commands as the Node CLI, minus the Claude Code hook."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import VERSION, build, capture_dir, scan, to_otlp, validate, write_capture, write_note
|
|
10
|
+
|
|
11
|
+
HELP = f"""firstwatch {VERSION}: watch, record and stop attacks aimed at AI agents
|
|
12
|
+
|
|
13
|
+
firstwatch scan [file] scan a file (or stdin) and print findings as JSON
|
|
14
|
+
firstwatch capture read a capture as JSON on stdin, check it, save it
|
|
15
|
+
firstwatch list list saved captures
|
|
16
|
+
firstwatch validate <capture.json> check a capture against the format
|
|
17
|
+
firstwatch export otlp <capture.json> print it as an OpenTelemetry (OTLP/JSON) log record
|
|
18
|
+
firstwatch clear [--session <id>] lift the approval gate for one session, or all
|
|
19
|
+
|
|
20
|
+
Captures go to ./security-captures, or FIRSTWATCH_DIR if set.
|
|
21
|
+
The Claude Code hook ships in the Node package: npx firstwatch hook claude-code"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _fail(msg):
|
|
25
|
+
sys.stderr.write(f"firstwatch: {msg}\n")
|
|
26
|
+
sys.exit(1)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _print(v):
|
|
30
|
+
sys.stdout.write((v if isinstance(v, str) else json.dumps(v, indent=2, ensure_ascii=False)) + "\n")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _read(path):
|
|
34
|
+
return Path(path).read_text(encoding="utf-8")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main(argv=None):
|
|
38
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
39
|
+
cmd = args.pop(0) if args else None
|
|
40
|
+
if cmd in (None, "-h", "--help", "help"):
|
|
41
|
+
return _print(HELP)
|
|
42
|
+
if cmd in ("-v", "--version"):
|
|
43
|
+
return _print(VERSION)
|
|
44
|
+
|
|
45
|
+
if cmd == "scan":
|
|
46
|
+
text = _read(args[0]) if args and args[0] != "-" else sys.stdin.read()
|
|
47
|
+
return _print(scan(text))
|
|
48
|
+
|
|
49
|
+
if cmd == "capture":
|
|
50
|
+
try:
|
|
51
|
+
data = json.loads(sys.stdin.read())
|
|
52
|
+
except ValueError as e:
|
|
53
|
+
_fail(f"stdin must be JSON: {e}")
|
|
54
|
+
text = (data.get("payload") or {}).get("text") or data.get("text")
|
|
55
|
+
if not text:
|
|
56
|
+
_fail("payload.text (the exact text of the attack) is required")
|
|
57
|
+
data["payload"] = {"hidden": False, "hidden_how": [], **(data.get("payload") or {}), "text": text}
|
|
58
|
+
found = scan(text)["findings"]
|
|
59
|
+
data["findings"] = (data.get("findings") or []) + found
|
|
60
|
+
data["level"] = data.get("level") or ("stop" if any(f["level"] == "stop" for f in found) else "note")
|
|
61
|
+
rec = build(data)
|
|
62
|
+
problems = validate(rec)
|
|
63
|
+
if problems:
|
|
64
|
+
_fail("capture is incomplete:\n- " + "\n- ".join(problems))
|
|
65
|
+
raw_file = data.get("raw_file")
|
|
66
|
+
raw = _read(raw_file) if raw_file and os.path.exists(raw_file) else None
|
|
67
|
+
if rec["level"] == "stop":
|
|
68
|
+
where = write_capture(rec, raw=raw, raw_kind="html" if re.search(r"\.html?$", raw_file or "", re.I) else "text")
|
|
69
|
+
else:
|
|
70
|
+
where = write_note(rec)
|
|
71
|
+
return _print({"level": rec["level"], "saved": where, "findings": len(rec["findings"])})
|
|
72
|
+
|
|
73
|
+
if cmd == "list":
|
|
74
|
+
f = capture_dir() / "index.jsonl"
|
|
75
|
+
if not f.exists():
|
|
76
|
+
return _print("No captures yet.")
|
|
77
|
+
for line in f.read_text(encoding="utf-8").strip().splitlines():
|
|
78
|
+
try:
|
|
79
|
+
r = json.loads(line)
|
|
80
|
+
except ValueError:
|
|
81
|
+
continue
|
|
82
|
+
_print(f"{r['time']} {r['level'].upper()} {r['summary']}\n {r['folder']}")
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
if cmd == "validate":
|
|
86
|
+
if not args:
|
|
87
|
+
_fail("usage: firstwatch validate <capture.json>")
|
|
88
|
+
problems = validate(json.loads(_read(args[0])))
|
|
89
|
+
if problems:
|
|
90
|
+
_fail("not valid:\n- " + "\n- ".join(problems))
|
|
91
|
+
return _print("valid")
|
|
92
|
+
|
|
93
|
+
if cmd == "export":
|
|
94
|
+
if len(args) < 2 or args[0] != "otlp":
|
|
95
|
+
_fail("usage: firstwatch export otlp <capture.json>")
|
|
96
|
+
return _print(to_otlp(json.loads(_read(args[1]))))
|
|
97
|
+
|
|
98
|
+
if cmd == "clear":
|
|
99
|
+
d = capture_dir() / ".sessions"
|
|
100
|
+
if not d.exists():
|
|
101
|
+
return _print("No gated sessions.")
|
|
102
|
+
if "--session" in args:
|
|
103
|
+
sid = args[args.index("--session") + 1]
|
|
104
|
+
files = [d / (re.sub(r"[^a-z0-9]+", "-", sid.lower()) + ".json")]
|
|
105
|
+
else:
|
|
106
|
+
files = list(d.iterdir())
|
|
107
|
+
for f in files:
|
|
108
|
+
f.unlink(missing_ok=True)
|
|
109
|
+
return _print("Lifted the gate for " + (f"session {sid}" if "--session" in args else "all sessions") + ".")
|
|
110
|
+
|
|
111
|
+
_fail(f'unknown command "{cmd}"\n\n{HELP}')
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
main()
|
firstwatch/redact.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Mask secrets before anything is written to disk. Emails are masked too unless the
|
|
2
|
+
caller keeps them (an attacker's address in the payload is evidence). Mirrors src/redact.js."""
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
_A = re.ASCII
|
|
7
|
+
SECRETS = [
|
|
8
|
+
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----"),
|
|
9
|
+
re.compile(r"\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}", _A),
|
|
10
|
+
re.compile(r"\b(?:ghp|gho|ghs|ghu|ghr)_[A-Za-z0-9]{30,}\b", _A),
|
|
11
|
+
re.compile(r"\bgithub_pat_[A-Za-z0-9_]{40,}\b", _A),
|
|
12
|
+
re.compile(r"\bAKIA[0-9A-Z]{16}\b", _A),
|
|
13
|
+
re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}", _A),
|
|
14
|
+
re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b", _A),
|
|
15
|
+
re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}", _A),
|
|
16
|
+
re.compile(r"\b(?:hf|npm|glpat|pypi)[-_][A-Za-z0-9_-]{20,}", _A),
|
|
17
|
+
]
|
|
18
|
+
BEARER = re.compile(r"\b(Bearer|Basic)\s+[A-Za-z0-9._~+/-]{16,}=*", _A)
|
|
19
|
+
ASSIGNED = re.compile(r"\b([\w-]*(?:api[_-]?key|token|secret|passw(?:or)?d|pwd|auth)[\w-]*)([\"']?\s*[:=]\s*[\"']?)([^\s\"',;]{8,})", re.I | _A)
|
|
20
|
+
EMAIL = re.compile(r"\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b", _A)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def redact(text, keep_emails=False):
|
|
24
|
+
"""Return (masked_text, count)."""
|
|
25
|
+
if not isinstance(text, str):
|
|
26
|
+
return text, 0
|
|
27
|
+
count = 0
|
|
28
|
+
|
|
29
|
+
def sub(rx, repl, s):
|
|
30
|
+
nonlocal count
|
|
31
|
+
out, n = rx.subn(repl, s)
|
|
32
|
+
count += n
|
|
33
|
+
return out
|
|
34
|
+
|
|
35
|
+
out = text
|
|
36
|
+
for rx in SECRETS:
|
|
37
|
+
out = sub(rx, "[REDACTED:secret]", out)
|
|
38
|
+
out = sub(BEARER, lambda m: f"{m.group(1)} [REDACTED:secret]", out)
|
|
39
|
+
|
|
40
|
+
def assigned(m):
|
|
41
|
+
nonlocal count
|
|
42
|
+
if m.group(3).startswith("[REDACTED"):
|
|
43
|
+
return m.group(0)
|
|
44
|
+
count += 1
|
|
45
|
+
return f"{m.group(1)}{m.group(2)}[REDACTED:secret]"
|
|
46
|
+
|
|
47
|
+
out = ASSIGNED.sub(assigned, out)
|
|
48
|
+
if not keep_emails:
|
|
49
|
+
out = sub(EMAIL, "[REDACTED:email]", out)
|
|
50
|
+
return out, count
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def redact_deep(value, keep_emails=False):
|
|
54
|
+
"""Redact every string inside a JSON-like value. Returns (value, count)."""
|
|
55
|
+
total = 0
|
|
56
|
+
|
|
57
|
+
def walk(v):
|
|
58
|
+
nonlocal total
|
|
59
|
+
if isinstance(v, str):
|
|
60
|
+
out, n = redact(v, keep_emails)
|
|
61
|
+
total += n
|
|
62
|
+
return out
|
|
63
|
+
if isinstance(v, list):
|
|
64
|
+
return [walk(x) for x in v]
|
|
65
|
+
if isinstance(v, dict):
|
|
66
|
+
return {k: walk(x) for k, x in v.items()}
|
|
67
|
+
return v
|
|
68
|
+
|
|
69
|
+
return walk(value), total
|
firstwatch/rules.json
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "0.1.0",
|
|
3
|
+
"_about": "firstwatch detection rules. Shared by the Node and Python packages; edit here only. Patterns use the common subset of JavaScript and Python regex syntax. Levels: visible = when people can see the text (null = only when hidden or addressed to an AI), hidden = when the text is hidden from people.",
|
|
4
|
+
"atlas": {
|
|
5
|
+
"AML.T0051.001": "LLM Prompt Injection: Indirect",
|
|
6
|
+
"AML.T0054": "LLM Jailbreak",
|
|
7
|
+
"AML.T0057": "LLM Data Leakage",
|
|
8
|
+
"AML.T0068": "LLM Prompt Obfuscation",
|
|
9
|
+
"AML.T0081": "Modify AI Agent Configuration",
|
|
10
|
+
"AML.T0086": "Exfiltration via AI Agent Tool Invocation",
|
|
11
|
+
"AML.T0100": "AI Agent Clickbait"
|
|
12
|
+
},
|
|
13
|
+
"owasp": {
|
|
14
|
+
"LLM01:2025": "Prompt Injection",
|
|
15
|
+
"LLM02:2025": "Sensitive Information Disclosure",
|
|
16
|
+
"LLM06:2025": "Excessive Agency",
|
|
17
|
+
"LLM07:2025": "System Prompt Leakage"
|
|
18
|
+
},
|
|
19
|
+
"agent": {
|
|
20
|
+
"source": "(?:\\b(?:ai|llms?|gpt|claude|gemini|chatgpt|copilot)\\b|\\blanguage models?\\b|\\b(?:assistants?|chatbots?)\\b)",
|
|
21
|
+
"flags": "i"
|
|
22
|
+
},
|
|
23
|
+
"rules": [
|
|
24
|
+
{
|
|
25
|
+
"id": "override-instructions",
|
|
26
|
+
"what": "tells the agent to ignore or replace its instructions",
|
|
27
|
+
"visible": "stop",
|
|
28
|
+
"hidden": "stop",
|
|
29
|
+
"atlas": [
|
|
30
|
+
"AML.T0051.001"
|
|
31
|
+
],
|
|
32
|
+
"owasp": [
|
|
33
|
+
"LLM01:2025"
|
|
34
|
+
],
|
|
35
|
+
"pattern": {
|
|
36
|
+
"source": "\\b(?:ignore|disregard|forget|override|bypass)\\b[^.\\n]{0,40}?\\b(?:previous|prior|above|earlier|all|any|your|system|original|preceding)\\b[^.\\n]{0,20}?\\b(?:instructions?|prompts?|rules|directions|guidelines|directives|context|constraints)\\b",
|
|
37
|
+
"flags": "i"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"id": "role-hijack",
|
|
42
|
+
"what": "tries to switch the agent into an unrestricted role",
|
|
43
|
+
"visible": "stop",
|
|
44
|
+
"hidden": "stop",
|
|
45
|
+
"atlas": [
|
|
46
|
+
"AML.T0054"
|
|
47
|
+
],
|
|
48
|
+
"owasp": [
|
|
49
|
+
"LLM01:2025"
|
|
50
|
+
],
|
|
51
|
+
"pattern": {
|
|
52
|
+
"source": "\\byou are now (?:in )?(?:dan\\b|an? (?:unrestricted|unfiltered|jailbroken)|unrestricted|jailbroken|in developer mode)|\\b(?:enter|enable|activate) (?:dan|god|jailbreak) mode\\b|\\bact as dan\\b",
|
|
53
|
+
"flags": "i"
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"id": "new-instructions",
|
|
58
|
+
"what": "announces new instructions for the reader",
|
|
59
|
+
"visible": "note",
|
|
60
|
+
"hidden": "stop",
|
|
61
|
+
"atlas": [
|
|
62
|
+
"AML.T0051.001"
|
|
63
|
+
],
|
|
64
|
+
"owasp": [
|
|
65
|
+
"LLM01:2025"
|
|
66
|
+
],
|
|
67
|
+
"pattern": {
|
|
68
|
+
"source": "\\b(?:new|updated|real|actual|true|secret|hidden|additional) (?:system )?(?:instructions?|directives?|orders|task)\\s*[:\\-]",
|
|
69
|
+
"flags": "i"
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"id": "conceal-from-user",
|
|
74
|
+
"what": "tells the agent to hide something from its user",
|
|
75
|
+
"visible": "stop",
|
|
76
|
+
"hidden": "stop",
|
|
77
|
+
"atlas": [
|
|
78
|
+
"AML.T0051.001"
|
|
79
|
+
],
|
|
80
|
+
"owasp": [
|
|
81
|
+
"LLM01:2025"
|
|
82
|
+
],
|
|
83
|
+
"pattern": {
|
|
84
|
+
"source": "\\b(?:do not|don'?t|never)\\s+(?:tell|inform|mention|reveal|show|alert|notify|let)\\b[^.\\n]{0,30}?\\b(?:the )?(?:user|human|owner|operator|anyone)\\b|\\bwithout (?:telling|informing|alerting|notifying|asking)\\b[^.\\n]{0,20}?\\b(?:the )?(?:user|human|owner|operator)\\b",
|
|
85
|
+
"flags": "i"
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"id": "exfiltrate-secrets",
|
|
90
|
+
"what": "asks for secrets to be sent to an outside address",
|
|
91
|
+
"visible": "stop",
|
|
92
|
+
"hidden": "stop",
|
|
93
|
+
"atlas": [
|
|
94
|
+
"AML.T0086",
|
|
95
|
+
"AML.T0057"
|
|
96
|
+
],
|
|
97
|
+
"owasp": [
|
|
98
|
+
"LLM02:2025",
|
|
99
|
+
"LLM06:2025"
|
|
100
|
+
],
|
|
101
|
+
"pattern": {
|
|
102
|
+
"source": "\\b(?:send|e-?mail|post|upload|share|forward|transmit|exfiltrate|leak|paste|include|append|attach|submit)\\b[^\\n]{0,80}?(?:\\.env\\b|api[ _-]?keys?|access[ _-]?tokens?|auth(?:entication)?[ _-]?tokens?|secrets?|passwords?|credentials?|private[ _-]?keys?|ssh[ _-]?keys?|id_rsa|aws_[a-z_]+|session[ _-]?(?:tokens?|cookies?)|cookies|seed[ _-]?phrase|wallet)[^\\n]{0,80}?(?:[\\w.+-]+@[\\w-]+\\.[\\w.]+|https?:\\/\\/\\S+|\\bto (?:this|the following|our|my) (?:address|url|endpoint|webhook|server|email)|\\bin (?:your|the) (?:reply|response|answer|output))|(?:\\.env\\b|api[ _-]?keys?|access[ _-]?tokens?|auth(?:entication)?[ _-]?tokens?|secrets?|passwords?|credentials?|private[ _-]?keys?|ssh[ _-]?keys?|id_rsa|aws_[a-z_]+|session[ _-]?(?:tokens?|cookies?)|cookies|seed[ _-]?phrase|wallet)[^\\n]{0,40}?\\b(?:send|e-?mail|post|upload|share|forward|transmit|exfiltrate|leak|paste|include|append|attach|submit)\\b[^\\n]{0,60}?(?:[\\w.+-]+@[\\w-]+\\.[\\w.]+|https?:\\/\\/\\S+|\\bto (?:this|the following|our|my) (?:address|url|endpoint|webhook|server|email)|\\bin (?:your|the) (?:reply|response|answer|output))",
|
|
103
|
+
"flags": "i"
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"id": "reveal-instructions",
|
|
108
|
+
"what": "asks the agent to reveal its instructions",
|
|
109
|
+
"visible": "stop",
|
|
110
|
+
"hidden": "stop",
|
|
111
|
+
"atlas": [
|
|
112
|
+
"AML.T0057"
|
|
113
|
+
],
|
|
114
|
+
"owasp": [
|
|
115
|
+
"LLM07:2025"
|
|
116
|
+
],
|
|
117
|
+
"pattern": {
|
|
118
|
+
"source": "\\b(?:reveal|print|output|repeat|show|disclose|leak|share|tell me)\\b[^.\\n]{0,30}?\\b(?:(?:your|the) system prompt|your (?:instructions|initial prompt|hidden prompt|rules|configuration)|the (?:initial|hidden) prompt)\\b",
|
|
119
|
+
"flags": "i"
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"id": "remote-code",
|
|
124
|
+
"what": "pipes a download straight into a shell",
|
|
125
|
+
"visible": null,
|
|
126
|
+
"hidden": "stop",
|
|
127
|
+
"atlas": [
|
|
128
|
+
"AML.T0051.001"
|
|
129
|
+
],
|
|
130
|
+
"owasp": [
|
|
131
|
+
"LLM06:2025"
|
|
132
|
+
],
|
|
133
|
+
"pattern": {
|
|
134
|
+
"source": "\\b(?:curl|wget)\\b[^\\n|]{0,200}\\|\\s*(?:sudo\\s+)?(?:ba|z)?sh\\b|\\b(?:iex|invoke-expression)\\b[^\\n]{0,40}\\b(?:iwr|invoke-webrequest|downloadstring)\\b",
|
|
135
|
+
"flags": "i"
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
"id": "image-exfiltration",
|
|
140
|
+
"what": "markdown image whose URL carries a data placeholder",
|
|
141
|
+
"visible": "stop",
|
|
142
|
+
"hidden": "stop",
|
|
143
|
+
"atlas": [
|
|
144
|
+
"AML.T0086"
|
|
145
|
+
],
|
|
146
|
+
"owasp": [
|
|
147
|
+
"LLM02:2025"
|
|
148
|
+
],
|
|
149
|
+
"pattern": {
|
|
150
|
+
"source": "!\\[[^\\]]*\\]\\(\\s*https?:\\/\\/[^)\\s]*[?&][\\w-]+=\\s*(?:\\{|\\[|<|%7b|\\$\\{)",
|
|
151
|
+
"flags": "i"
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
"id": "modify-agent-config",
|
|
156
|
+
"what": "asks for changes to the agent's own setup",
|
|
157
|
+
"visible": "note",
|
|
158
|
+
"hidden": "stop",
|
|
159
|
+
"atlas": [
|
|
160
|
+
"AML.T0081"
|
|
161
|
+
],
|
|
162
|
+
"owasp": [
|
|
163
|
+
"LLM06:2025"
|
|
164
|
+
],
|
|
165
|
+
"pattern": {
|
|
166
|
+
"source": "\\b(?:edit|modify|update|append|write|add|overwrite|replace)\\b[^\\n]{0,100}?(?:\\bCLAUDE\\.md\\b|\\bAGENTS\\.md\\b|\\.cursorrules\\b|\\bsettings\\.json\\b|\\bMEMORY\\.md\\b|\\byour (?:memory|instructions|system prompt|hooks?|rules)\\b|\\.bashrc\\b|\\.zshrc\\b|\\bcrontab\\b|\\bauthorized_keys\\b)",
|
|
167
|
+
"flags": "i"
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
"id": "agent-imperative",
|
|
172
|
+
"what": "gives orders to AI agents",
|
|
173
|
+
"visible": "note",
|
|
174
|
+
"hidden": "stop",
|
|
175
|
+
"atlas": [
|
|
176
|
+
"AML.T0051.001"
|
|
177
|
+
],
|
|
178
|
+
"owasp": [
|
|
179
|
+
"LLM01:2025"
|
|
180
|
+
],
|
|
181
|
+
"agent": {
|
|
182
|
+
"source": "(?:\\b(?:ai|llms?|gpt|claude|gemini|chatgpt|copilot)\\b|\\blanguage models?\\b|\\b(?:assistants?|chatbots?)\\b)",
|
|
183
|
+
"flags": "i"
|
|
184
|
+
},
|
|
185
|
+
"imperative": {
|
|
186
|
+
"source": "(?:\\byou (?:must|should|need to|are required to|will|have to)\\b|\\bdo not\\b|\\bdon'?t\\b|\\bnever\\b|\\balways\\b|\\bimmediately\\b|\\bbefore (?:you )?(?:answer|respond|reply|continue|proceed)\\b|\\bmake sure\\b)",
|
|
187
|
+
"flags": "i"
|
|
188
|
+
},
|
|
189
|
+
"danger": {
|
|
190
|
+
"source": "\\b(?:send|e-?mail|forward|upload|transfer|wire|pay|buy|purchase|delete|wipe|reveal|disclose|grant|authori[sz]e|approve|sign ?up|subscribe|share (?:your|the|all))\\b",
|
|
191
|
+
"flags": "i"
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
],
|
|
195
|
+
"hidden_style": [
|
|
196
|
+
{
|
|
197
|
+
"label": "css:display-none",
|
|
198
|
+
"source": "display\\s*:\\s*none",
|
|
199
|
+
"flags": "i"
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
"label": "css:visibility-hidden",
|
|
203
|
+
"source": "visibility\\s*:\\s*hidden",
|
|
204
|
+
"flags": "i"
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
"label": "css:tiny-text",
|
|
208
|
+
"source": "font-size\\s*:\\s*(?:0|0?\\.\\d+|1)(?:px|pt|em|rem)?\\s*(?:;|$|!)",
|
|
209
|
+
"flags": "i"
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
"label": "css:opacity-0",
|
|
213
|
+
"source": "opacity\\s*:\\s*0(?:\\.0+)?\\s*(?:;|$|!)",
|
|
214
|
+
"flags": "i"
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
"label": "css:off-screen",
|
|
218
|
+
"source": "(?:left|top|right|text-indent|margin-left)\\s*:\\s*-\\d{3,}",
|
|
219
|
+
"flags": "i"
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
"label": "css:white-or-transparent-text",
|
|
223
|
+
"source": "(?:^|[;\\s])color\\s*:\\s*(?:#fff\\b|#ffffff\\b|white\\b|transparent\\b|rgba\\([^)]*,\\s*0\\s*\\))",
|
|
224
|
+
"flags": "i"
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
"label": "css:zero-size",
|
|
228
|
+
"source": "(?:height|width)\\s*:\\s*0(?:px)?\\s*;[^\\\"']*overflow\\s*:\\s*hidden",
|
|
229
|
+
"flags": "i"
|
|
230
|
+
}
|
|
231
|
+
],
|
|
232
|
+
"discussion": {
|
|
233
|
+
"_about": "A page that mentions these words this many times in visible text discusses attacks: its visible stops become notes. Hidden text still stops.",
|
|
234
|
+
"source": "prompt[ -]?injections?|jailbreak",
|
|
235
|
+
"flags": "gi",
|
|
236
|
+
"min": 3
|
|
237
|
+
},
|
|
238
|
+
"priority": [
|
|
239
|
+
"exfiltrate-secrets",
|
|
240
|
+
"image-exfiltration",
|
|
241
|
+
"remote-code",
|
|
242
|
+
"modify-agent-config",
|
|
243
|
+
"override-instructions",
|
|
244
|
+
"role-hijack",
|
|
245
|
+
"reveal-instructions",
|
|
246
|
+
"unicode-tag-smuggling",
|
|
247
|
+
"conceal-from-user",
|
|
248
|
+
"agent-imperative",
|
|
249
|
+
"new-instructions",
|
|
250
|
+
"hidden-agent-text",
|
|
251
|
+
"bidi-control"
|
|
252
|
+
],
|
|
253
|
+
"builtin": [
|
|
254
|
+
{
|
|
255
|
+
"id": "unicode-tag-smuggling",
|
|
256
|
+
"what": "hides text in invisible Unicode tag characters",
|
|
257
|
+
"level": "stop",
|
|
258
|
+
"atlas": [
|
|
259
|
+
"AML.T0068",
|
|
260
|
+
"AML.T0051.001"
|
|
261
|
+
],
|
|
262
|
+
"owasp": [
|
|
263
|
+
"LLM01:2025"
|
|
264
|
+
]
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
"id": "bidi-control",
|
|
268
|
+
"what": "contains characters that reorder text on screen",
|
|
269
|
+
"level": "note",
|
|
270
|
+
"atlas": [
|
|
271
|
+
"AML.T0068"
|
|
272
|
+
],
|
|
273
|
+
"owasp": [
|
|
274
|
+
"LLM01:2025"
|
|
275
|
+
]
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
"id": "hidden-agent-text",
|
|
279
|
+
"what": "hidden text that mentions AI agents",
|
|
280
|
+
"level": "note",
|
|
281
|
+
"atlas": [
|
|
282
|
+
"AML.T0051.001"
|
|
283
|
+
],
|
|
284
|
+
"owasp": [
|
|
285
|
+
"LLM01:2025"
|
|
286
|
+
]
|
|
287
|
+
}
|
|
288
|
+
]
|
|
289
|
+
}
|
firstwatch/scan.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Scan text or HTML for attacks aimed at AI agents. Pure: text in, findings out.
|
|
2
|
+
|
|
3
|
+
No network, no model. It splits the input into what people see and what they
|
|
4
|
+
don't, then runs the rules on each part. Mirrors src/scan.js in the Node package.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import html
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from ._rules import AGENT_RE, BUILTIN, DISCUSSION_MIN, DISCUSSION_RE, HIDDEN_STYLE, RULES
|
|
12
|
+
|
|
13
|
+
ZERO_WIDTH = re.compile("[\u200B-\u200D\u2060-\u2064\uFEFF\u00AD]")
|
|
14
|
+
BIDI = re.compile("[\u202A-\u202E\u2066-\u2069]")
|
|
15
|
+
TAG_CHARS = re.compile("[\U000e0000-\U000e007f]+")
|
|
16
|
+
LEVELS = {"clean": 0, "note": 1, "stop": 2}
|
|
17
|
+
_SPACE = re.compile(r"\s+")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def decode_entities(s):
|
|
21
|
+
def num(m, base):
|
|
22
|
+
try:
|
|
23
|
+
return chr(int(m.group(1), base))
|
|
24
|
+
except (ValueError, OverflowError):
|
|
25
|
+
return ""
|
|
26
|
+
|
|
27
|
+
s = re.sub(r"&#x([0-9a-f]+);", lambda m: num(m, 16), s, flags=re.I)
|
|
28
|
+
s = re.sub(r"&#(\d+);", lambda m: num(m, 10), s)
|
|
29
|
+
for a, b in ((" ", " "), (""", '"'), ("'", "'"), ("'", "'"), ("<", "<"), (">", ">"), ("&", "&")):
|
|
30
|
+
s = s.replace(a, b)
|
|
31
|
+
return s
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _strip_tags(s):
|
|
35
|
+
return _SPACE.sub(" ", decode_entities(re.sub(r"<[^>]*>", " ", s))).strip()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _inside(ranges, i):
|
|
39
|
+
return any(a <= i < b for a, b in ranges)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def segments(text):
|
|
43
|
+
"""Split input into segments: dicts with text, hidden, how, where, offset, quoted."""
|
|
44
|
+
out = []
|
|
45
|
+
for m in TAG_CHARS.finditer(text):
|
|
46
|
+
decoded = "".join(chr(ord(c) - 0xE0000) for c in m.group(0))
|
|
47
|
+
out.append({"text": decoded, "hidden": True, "how": ["unicode-tag-characters"], "where": "invisible characters", "offset": m.start()})
|
|
48
|
+
s = TAG_CHARS.sub("", text)
|
|
49
|
+
if not re.search(r"</?[a-z][\w-]*[^>]*>|<!--", s, re.I):
|
|
50
|
+
out.append({"text": s, "hidden": False, "how": [], "where": "text", "offset": 0})
|
|
51
|
+
return _with_base64(out, s)
|
|
52
|
+
|
|
53
|
+
lower = s.lower()
|
|
54
|
+
removed = []
|
|
55
|
+
for m in re.finditer(r"<!--([\s\S]*?)-->", s):
|
|
56
|
+
out.append({"text": m.group(1).strip(), "hidden": True, "how": ["html-comment"], "where": "HTML comment", "offset": m.start()})
|
|
57
|
+
removed.append((m.start(), m.end()))
|
|
58
|
+
for m in re.finditer(r"<(script|style|template)\b[^>]*>[\s\S]*?</\1>", s, re.I):
|
|
59
|
+
removed.append((m.start(), m.end()))
|
|
60
|
+
|
|
61
|
+
for m in re.finditer(r"<([a-z][\w-]*)\b([^>]*)>", s, re.I | re.ASCII):
|
|
62
|
+
tag, attrs = m.group(1).lower(), m.group(2)
|
|
63
|
+
how = []
|
|
64
|
+
style = re.search(r"\bstyle\s*=\s*(\"([^\"]*)\"|'([^']*)')", attrs, re.I)
|
|
65
|
+
if style:
|
|
66
|
+
value = style.group(2) if style.group(2) is not None else style.group(3)
|
|
67
|
+
how += [label for rx, label in HIDDEN_STYLE if rx.search(value)]
|
|
68
|
+
if re.search(r"(?:^|\s)hidden(?:\s|=|$)", attrs, re.I):
|
|
69
|
+
how.append("attr:hidden")
|
|
70
|
+
if re.search(r"aria-hidden\s*=\s*[\"']?true", attrs, re.I):
|
|
71
|
+
how.append("attr:aria-hidden")
|
|
72
|
+
if re.search(r"\bclass\s*=\s*[\"'][^\"']*\b(?:hidden|d-none|invisible)\b", attrs, re.I):
|
|
73
|
+
how.append("class:hidden")
|
|
74
|
+
for a in re.finditer(r"\b(alt|title|aria-label|content|data-[\w-]+)\s*=\s*(\"([^\"]*)\"|'([^']*)')", attrs, re.I | re.ASCII):
|
|
75
|
+
val = decode_entities(a.group(3) if a.group(3) is not None else a.group(4)).strip()
|
|
76
|
+
if len(val) > 15:
|
|
77
|
+
name = a.group(1).lower()
|
|
78
|
+
out.append({"text": val, "hidden": True, "how": [f"attribute:{name}"], "where": f"<{tag}> {name} attribute", "offset": m.start()})
|
|
79
|
+
if not how or re.fullmatch(r"br|img|input|meta|link|hr", tag):
|
|
80
|
+
continue
|
|
81
|
+
start = m.end()
|
|
82
|
+
close = lower.find(f"</{tag}", start)
|
|
83
|
+
end = min(len(s), start + 4000) if close == -1 else close
|
|
84
|
+
inner = _strip_tags(s[start:end])
|
|
85
|
+
if inner:
|
|
86
|
+
out.append({"text": inner, "hidden": True, "how": how, "where": f"hidden <{tag}>", "offset": m.start()})
|
|
87
|
+
removed.append((m.start(), end))
|
|
88
|
+
|
|
89
|
+
# Code blocks are quoted examples: a match there is a note, not a stop.
|
|
90
|
+
for m in re.finditer(r"<(pre|code)\b[^>]*>([\s\S]*?)</\1>", s, re.I):
|
|
91
|
+
if _inside(removed, m.start()):
|
|
92
|
+
continue
|
|
93
|
+
inner = _strip_tags(m.group(2))
|
|
94
|
+
if inner:
|
|
95
|
+
out.append({"text": inner, "hidden": False, "how": [], "where": f"<{m.group(1).lower()}> block", "offset": m.start(), "quoted": True})
|
|
96
|
+
removed.append((m.start(), m.end()))
|
|
97
|
+
|
|
98
|
+
visible, pos = [], 0
|
|
99
|
+
for a, b in sorted(removed):
|
|
100
|
+
if a > pos:
|
|
101
|
+
visible.append(s[pos:a] + " ")
|
|
102
|
+
pos = max(pos, b)
|
|
103
|
+
visible.append(s[pos:])
|
|
104
|
+
vis = _strip_tags("".join(visible))
|
|
105
|
+
if vis:
|
|
106
|
+
out.append({"text": vis, "hidden": False, "how": [], "where": "visible text", "offset": 0})
|
|
107
|
+
return _with_base64(out, s)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _with_base64(out, s):
|
|
111
|
+
for m in re.finditer(r"(?<![A-Za-z0-9+/=])[A-Za-z0-9+/]{40,}={0,2}(?![A-Za-z0-9+/=])", s):
|
|
112
|
+
blob = m.group(0).rstrip("=")
|
|
113
|
+
if len(blob) % 4 == 1:
|
|
114
|
+
blob = blob[:-1]
|
|
115
|
+
try:
|
|
116
|
+
decoded = base64.b64decode(blob + "=" * (-len(blob) % 4)).decode("utf-8", errors="replace")
|
|
117
|
+
except ValueError:
|
|
118
|
+
continue
|
|
119
|
+
printable = len(re.sub(r"[^\x20-\x7e\n\t]", "", decoded)) / max(len(decoded), 1)
|
|
120
|
+
if printable > 0.95 and re.search(r"[a-z]{3,} [a-z]{3,}", decoded, re.I):
|
|
121
|
+
out.append({"text": decoded, "hidden": True, "how": ["base64"], "where": "base64-encoded text", "offset": m.start()})
|
|
122
|
+
return out
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _excerpt(text, index, length):
|
|
126
|
+
a = max(0, index - 80)
|
|
127
|
+
b = min(len(text), index + length + 80)
|
|
128
|
+
return ("…" if a > 0 else "") + text[a:b] + ("…" if b < len(text) else "")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _in_quotes(text, i):
|
|
132
|
+
"""Is position i inside a quotation? Counts open quotes in the 300 characters before it."""
|
|
133
|
+
before = text[max(0, i - 300):i]
|
|
134
|
+
return before.count('"') % 2 == 1 or (before.count("“") + before.count("«")) > (before.count("”") + before.count("»"))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _level_for(rule, seg, text, index, near):
|
|
138
|
+
level = rule["hidden"] if seg["hidden"] else rule["visible"]
|
|
139
|
+
if level is None:
|
|
140
|
+
level = "stop" if AGENT_RE.search(near) else None
|
|
141
|
+
if not level:
|
|
142
|
+
return None
|
|
143
|
+
# A quoted example in visible text is someone talking about an attack, not making one.
|
|
144
|
+
if level == "stop" and not seg["hidden"] and (seg.get("quoted") or _in_quotes(text, index)):
|
|
145
|
+
level = "note"
|
|
146
|
+
return level
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _finding(rule, level, seg, ex):
|
|
150
|
+
atlas = list(rule["atlas"])
|
|
151
|
+
if any(h in ("base64", "zero-width-characters") for h in seg["how"]) and "AML.T0068" not in atlas:
|
|
152
|
+
atlas.append("AML.T0068")
|
|
153
|
+
return {
|
|
154
|
+
"rule": rule["id"],
|
|
155
|
+
"what": rule["what"],
|
|
156
|
+
"level": level,
|
|
157
|
+
"hidden": bool(seg["hidden"]),
|
|
158
|
+
"hidden_how": seg["how"],
|
|
159
|
+
"where": seg["where"],
|
|
160
|
+
"offset": seg["offset"],
|
|
161
|
+
"excerpt": ex,
|
|
162
|
+
"atlas": atlas,
|
|
163
|
+
"owasp": list(rule["owasp"]),
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def scan(text):
|
|
168
|
+
"""Scan a string. Returns {"level": "clean"|"note"|"stop", "findings": [...]}."""
|
|
169
|
+
if not isinstance(text, str) or not text:
|
|
170
|
+
return {"level": "clean", "findings": []}
|
|
171
|
+
findings, seen = [], set()
|
|
172
|
+
segs = segments(text)
|
|
173
|
+
if BIDI.search(text):
|
|
174
|
+
findings.append(_finding(BUILTIN["bidi-control"], "note", {"hidden": True, "how": ["bidi-control-characters"], "where": "text", "offset": 0}, "(bidirectional control characters present)"))
|
|
175
|
+
|
|
176
|
+
ai = next(r for r in RULES if r["id"] == "agent-imperative")
|
|
177
|
+
for seg in segs:
|
|
178
|
+
raw = seg["text"]
|
|
179
|
+
t = ZERO_WIDTH.sub("", raw)
|
|
180
|
+
how = list(seg["how"])
|
|
181
|
+
if t != raw:
|
|
182
|
+
how.append("zero-width-characters")
|
|
183
|
+
sf = dict(seg, how=how)
|
|
184
|
+
hit = False
|
|
185
|
+
|
|
186
|
+
if "unicode-tag-characters" in seg["how"]:
|
|
187
|
+
findings.append(_finding(BUILTIN["unicode-tag-smuggling"], "stop", sf, t[:240]))
|
|
188
|
+
hit = True
|
|
189
|
+
|
|
190
|
+
for rule in RULES:
|
|
191
|
+
if rule["id"] == "agent-imperative":
|
|
192
|
+
continue
|
|
193
|
+
m = rule["re"].search(t)
|
|
194
|
+
if not m:
|
|
195
|
+
continue
|
|
196
|
+
near = t[max(0, m.start() - 200):m.end() + 200]
|
|
197
|
+
level = _level_for(rule, sf, t, m.start(), near)
|
|
198
|
+
if not level:
|
|
199
|
+
continue
|
|
200
|
+
ex = _excerpt(t, m.start(), len(m.group(0)))
|
|
201
|
+
if (rule["id"] + ex) in seen:
|
|
202
|
+
continue
|
|
203
|
+
seen.add(rule["id"] + ex)
|
|
204
|
+
findings.append(_finding(rule, level, sf, ex))
|
|
205
|
+
hit = True
|
|
206
|
+
|
|
207
|
+
at = 0
|
|
208
|
+
for sentence in re.split(r"(?<=[.!?])\s+|\n+", t):
|
|
209
|
+
index = t.find(sentence, at)
|
|
210
|
+
at = index + len(sentence)
|
|
211
|
+
if not ai["agent_re"].search(sentence) or not ai["imperative_re"].search(sentence):
|
|
212
|
+
continue
|
|
213
|
+
level = "stop" if sf["hidden"] or ai["danger_re"].search(sentence) else "note"
|
|
214
|
+
if level == "stop" and not sf["hidden"] and (sf.get("quoted") or _in_quotes(t, index + 1)):
|
|
215
|
+
level = "note"
|
|
216
|
+
ex = sentence.strip()[:300]
|
|
217
|
+
if (ai["id"] + ex) in seen:
|
|
218
|
+
continue
|
|
219
|
+
seen.add(ai["id"] + ex)
|
|
220
|
+
findings.append(_finding(ai, level, sf, ex))
|
|
221
|
+
hit = True
|
|
222
|
+
|
|
223
|
+
# Hidden menus often name AI products; only a comment or invisible characters aimed at AI is worth a note.
|
|
224
|
+
if not hit and seg["hidden"] and AGENT_RE.search(t) and (seg["how"][:1] in (["html-comment"], ["unicode-tag-characters"])):
|
|
225
|
+
findings.append(_finding(BUILTIN["hidden-agent-text"], "note", sf, t[:240]))
|
|
226
|
+
|
|
227
|
+
# A page that talks about these attacks at length quotes them in visible text.
|
|
228
|
+
# Its visible examples become notes; anything hidden still stops.
|
|
229
|
+
visible = " ".join(x["text"] for x in segs if not x["hidden"])
|
|
230
|
+
if len(DISCUSSION_RE.findall(visible)) >= DISCUSSION_MIN:
|
|
231
|
+
for f in findings:
|
|
232
|
+
if f["level"] == "stop" and not f["hidden"]:
|
|
233
|
+
f["level"] = "note"
|
|
234
|
+
f["context"] = "page discusses attacks"
|
|
235
|
+
|
|
236
|
+
level = "clean"
|
|
237
|
+
for f in findings:
|
|
238
|
+
if LEVELS[f["level"]] > LEVELS[level]:
|
|
239
|
+
level = f["level"]
|
|
240
|
+
return {"level": level, "findings": findings}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: firstwatch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: First line of defence for AI agents: spots attacks hidden in pages, files and tool results, and records each one as evidence in an open, OpenTelemetry-ready format with MITRE ATLAS and OWASP IDs. No dependencies.
|
|
5
|
+
Project-URL: Homepage, https://github.com/dave8172/firstwatch
|
|
6
|
+
Project-URL: Documentation, https://github.com/dave8172/firstwatch/tree/main/docs
|
|
7
|
+
Project-URL: Source, https://github.com/dave8172/firstwatch
|
|
8
|
+
Project-URL: Issues, https://github.com/dave8172/firstwatch/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/dave8172/firstwatch/blob/main/CHANGELOG.md
|
|
10
|
+
Author: dave8172
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: agent-security,ai-agents,guardrails,llm-security,mitre-atlas,opentelemetry,owasp,prompt-injection,security
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
25
|
+
Classifier: Topic :: Security
|
|
26
|
+
Requires-Python: >=3.9
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# firstwatch
|
|
30
|
+
|
|
31
|
+
**First line of defence for AI agents.** Scans pages, files and tool results for attacks aimed at agents (in the text people see and the text they don't), and records each one as evidence in an open, OpenTelemetry-ready format with MITRE ATLAS and OWASP IDs.
|
|
32
|
+
|
|
33
|
+
No dependencies. Python 3.9+.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install firstwatch
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from firstwatch import scan, watch
|
|
41
|
+
|
|
42
|
+
scan(page_html)
|
|
43
|
+
# {"level": "stop", "findings": [{"rule": "exfiltrate-secrets", "hidden": True,
|
|
44
|
+
# "hidden_how": ["css:tiny-text"], "atlas": ["AML.T0086", ...], ...}]}
|
|
45
|
+
|
|
46
|
+
result = watch(page_html, url=url, tool="fetch") # scan + save a capture on a stop
|
|
47
|
+
if result["level"] == "stop":
|
|
48
|
+
... # don't hand the page to the model as instructions; tell the user
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Command line:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
firstwatch scan page.html
|
|
55
|
+
firstwatch list
|
|
56
|
+
firstwatch export otlp security-captures/<folder>/capture.json
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
It catches instruction overrides, role hijacks, orders to hide things from the user, requests to send secrets out, requests to reveal the system prompt, `curl | sh` in hidden text, markdown image data leaks, edits to the agent's own setup, and orders aimed at AI assistants. It looks in HTML comments, CSS-hidden elements, attributes, invisible Unicode tag characters, zero-width characters and base64.
|
|
60
|
+
|
|
61
|
+
The Node package (`npm install firstwatch`) uses the same rules and format, and adds Claude Code hooks that block actions after an attack.
|
|
62
|
+
|
|
63
|
+
**Docs, examples and the capture format:** https://github.com/dave8172/firstwatch
|
|
64
|
+
|
|
65
|
+
MIT licensed. Made by [@dave8172](https://x.com/dave8172).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
firstwatch/__init__.py,sha256=4aA7kX_RUGucGeKGqpH2vvk-96AqKXWKQbK3LBWTKvg,2488
|
|
2
|
+
firstwatch/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
|
|
3
|
+
firstwatch/_rules.py,sha256=qT3msZJf49VcW17Tf9I9ild8lvOQww9EdFlg6pUM1Ww,1112
|
|
4
|
+
firstwatch/capture.py,sha256=d57H0_qwvK_grd4g3NzhMZ-7aY75qsxwlt1x8azZV-g,11711
|
|
5
|
+
firstwatch/cli.py,sha256=cRNozfLPoxq4olaFGIm3xNOCzdvrrZ8_nbRTBHcB88I,4441
|
|
6
|
+
firstwatch/redact.py,sha256=PmqQqN9UiHYx4-mcOoHVOIaXTERY5BqRZMrmzIzv1Mc,2363
|
|
7
|
+
firstwatch/rules.json,sha256=Bb6AKqiX_sph0zlWXb9vjpPBl1KOhVmF-tJ-ZSXWA18,9953
|
|
8
|
+
firstwatch/scan.py,sha256=V7lB6p0s_3ZswemM710kfLUNk66nLvldRX5XK6_M6so,10144
|
|
9
|
+
firstwatch-0.1.0.dist-info/METADATA,sha256=2qjvTBT1cWFSo2_OIWM6tEclgUeI7F6xlZsVALUzrVQ,3042
|
|
10
|
+
firstwatch-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
11
|
+
firstwatch-0.1.0.dist-info/entry_points.txt,sha256=1da0Y_MWNUPkkD450ZlM-cIGrEtjo4RC3iIM0CqR5tc,51
|
|
12
|
+
firstwatch-0.1.0.dist-info/licenses/LICENSE,sha256=SaJIEtWVZ-SL5sXsd4HaAwLAUZrnZsMGZOiSK0kZt3I,1065
|
|
13
|
+
firstwatch-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dave8172
|
|
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.
|