scamscript 0.4.1__tar.gz → 0.6.0__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.
- {scamscript-0.4.1 → scamscript-0.6.0}/CHANGELOG.md +27 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/PKG-INFO +33 -4
- {scamscript-0.4.1 → scamscript-0.6.0}/README.md +32 -3
- {scamscript-0.4.1 → scamscript-0.6.0}/pyproject.toml +1 -1
- {scamscript-0.4.1 → scamscript-0.6.0}/src/scamscript/__init__.py +1 -1
- {scamscript-0.4.1 → scamscript-0.6.0}/src/scamscript/analysis.py +11 -2
- {scamscript-0.4.1 → scamscript-0.6.0}/src/scamscript/cli.py +62 -8
- {scamscript-0.4.1 → scamscript-0.6.0}/src/scamscript/client.py +1 -1
- scamscript-0.6.0/src/scamscript/dedupe.py +29 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/src/scamscript/render.py +2 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/src/scamscript/report.py +73 -4
- {scamscript-0.4.1 → scamscript-0.6.0}/src/scamscript/store.py +19 -1
- scamscript-0.6.0/src/scamscript/taxonomy.py +77 -0
- scamscript-0.6.0/tests/fixtures/reprints/copy-header.txt +4 -0
- scamscript-0.6.0/tests/fixtures/reprints/copy-punctuation.txt +11 -0
- scamscript-0.6.0/tests/fixtures/reprints/near-twin.txt +5 -0
- scamscript-0.6.0/tests/fixtures/reprints/original.txt +4 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/tests/test_cli.py +70 -1
- scamscript-0.6.0/tests/test_dedupe.py +60 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/tests/test_report.py +14 -1
- {scamscript-0.4.1 → scamscript-0.6.0}/tests/test_store.py +10 -0
- scamscript-0.6.0/tests/test_taxonomy.py +51 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/.github/SECURITY.md +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/.github/pull_request_template.md +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/.github/workflows/ci.yml +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/.github/workflows/release.yml +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/.gitignore +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/CITATION.cff +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/CODE_OF_CONDUCT.md +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/CONTRIBUTING.md +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/LICENSE +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/docs/banner.svg +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/docs/calibration.md +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/docs/demo.gif +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/docs/sample-report.md +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/docs/social-preview.png +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/examples/adversarial/fake_output_json.txt +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/examples/adversarial/fence_escape.txt +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/examples/adversarial/fixed_attack_type.txt +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/examples/adversarial/non_json_demand.txt +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/examples/adversarial/print_system_prompt.txt +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/examples/adversarial/role_switch.txt +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/examples/adversarial/score_override.txt +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/examples/crude_robocall.txt +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/examples/gov_impersonation_vishing.txt +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/examples/prompt_injection_script.txt +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/examples/romance_scam_sms.txt +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/examples/sophisticated_wire_fraud.txt +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/examples/tech_support_scam.txt +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/scripts/calibrate.py +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/scripts/injection_eval.py +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/src/scamscript/__main__.py +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/src/scamscript/config.py +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/src/scamscript/ingest.py +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/src/scamscript/models.py +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/src/scamscript/prompts.py +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/src/scamscript/redact.py +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/src/scamscript/verify.py +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/tests/test_analysis.py +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/tests/test_client.py +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/tests/test_ingest.py +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/tests/test_models.py +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/tests/test_prompts.py +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/tests/test_redact.py +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/tests/test_render.py +0 -0
- {scamscript-0.4.1 → scamscript-0.6.0}/tests/test_verify.py +0 -0
|
@@ -5,6 +5,33 @@ schema are the things that count as public API.
|
|
|
5
5
|
|
|
6
6
|
## Unreleased
|
|
7
7
|
|
|
8
|
+
## 0.6.0 - 2026-11-02
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Closed-set attack type slugs, a per-type count and mean score table, and `--type` filtering with an
|
|
13
|
+
unknown-token warning. Raw descriptions are preserved.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Panels show the slug beneath the attack type.
|
|
18
|
+
|
|
19
|
+
### Security
|
|
20
|
+
|
|
21
|
+
- Log directories are created `0700` and log files `0600`, so captures are not world-readable.
|
|
22
|
+
|
|
23
|
+
## 0.5.0 - 2026-11-02
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- Content fingerprints and duplicate folding: reprints collapse to the strongest copy in reports,
|
|
28
|
+
with a folded count and `duplicate_of` in JSON.
|
|
29
|
+
- `--dedupe/--no-dedupe` (on by default) and `--show-duplicates` to render every panel.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- Duplicate panels are replaced by a one-line pointer unless `--show-duplicates` is set.
|
|
34
|
+
|
|
8
35
|
## 0.4.1 - 2026-11-02
|
|
9
36
|
|
|
10
37
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: scamscript
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Reverse-engineer scam scripts and vishing transcripts into structured counter-playbooks
|
|
5
5
|
Project-URL: Homepage, https://github.com/sjdsjadjajdjsa/scamscript
|
|
6
6
|
Project-URL: Repository, https://github.com/sjdsjadjajdjsa/scamscript
|
|
@@ -133,7 +133,8 @@ corpus is at [`docs/sample-report.md`](https://github.com/sjdsjadjajdjsa/scamscr
|
|
|
133
133
|
| `prune` | Delete old logs |
|
|
134
134
|
|
|
135
135
|
Useful flags: `--dry-run`, `--json`, `--redact`, `--top N`, `--recursive`, `--timeout`,
|
|
136
|
-
`--retries`, `--max-tokens`, `--concurrency`, `--max-cost`, `--no-
|
|
136
|
+
`--retries`, `--max-tokens`, `--concurrency`, `--max-cost`, `--dedupe/--no-dedupe`,
|
|
137
|
+
`--show-duplicates`, `--type`, `--no-color`. `--json` puts one object per script on stdout and all
|
|
137
138
|
Rich output on stderr, so it pipes:
|
|
138
139
|
|
|
139
140
|
```bash
|
|
@@ -154,6 +155,33 @@ The cap reads `usage.cost` from the response, so it works only where the provide
|
|
|
154
155
|
says so when none is sent. Spend is printed and logged per run. In-flight requests can overshoot by
|
|
155
156
|
up to the concurrency, so keep headroom on a hard limit.
|
|
156
157
|
|
|
158
|
+
## Attack types
|
|
159
|
+
|
|
160
|
+
`attack_type` is free prose, which is useful to read and useless to group. Every result also carries a
|
|
161
|
+
closed-set slug (`vishing`, `smishing`, `tech-support`, `refund`, `gov-impersonation`, `bec`,
|
|
162
|
+
`romance`, `crypto`, `investment`, `utility`, `delivery`, `other`). Reports show counts and mean scores
|
|
163
|
+
per slug above the ranking, and `--type` filters to the ones you ask for:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
scamscript batch corpus/ --type tech-support,refund --output report.md
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The raw phrase is always kept next to the slug. An unrecognised description lands in `other` and the
|
|
170
|
+
CLI names any filter token it did not recognise.
|
|
171
|
+
|
|
172
|
+
## Duplicates
|
|
173
|
+
|
|
174
|
+
Corpora are full of reprints. Each script is fingerprinted on its body: headers, casing, punctuation
|
|
175
|
+
and whitespace are ignored, so copies collapse. The strongest copy is ranked and the rest are listed
|
|
176
|
+
under it; the report counts what was folded and JSON records carry `fingerprint` and `duplicate_of`.
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
scamscript batch corpus/ --output report.md # folds by default
|
|
180
|
+
scamscript batch corpus/ --no-dedupe --show-duplicates
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Only byte-level twins fold. A copy with one changed account number stays separate, by design.
|
|
184
|
+
|
|
157
185
|
## How it works
|
|
158
186
|
|
|
159
187
|
```text
|
|
@@ -190,7 +218,7 @@ masking is best effort: names, case IDs and spelled-out digits survive it.
|
|
|
190
218
|
Every request appends to a timestamped JSONL file under `~/.scamscript/logs/` (override with
|
|
191
219
|
`--home` or `SCAMSCRIPT_HOME`). The API key is never written. Logs are the source for `report`, so
|
|
192
220
|
a run replays without spending tokens, and `prune --keep 200 --keep-days 30` keeps the store
|
|
193
|
-
bounded.
|
|
221
|
+
bounded. Directories are `0700` and log files `0600`.
|
|
194
222
|
|
|
195
223
|
## Install and configure
|
|
196
224
|
|
|
@@ -262,7 +290,8 @@ docs/ calibration record, sample report, banner, demo
|
|
|
262
290
|
- Breakdowns are model readings; a bad prompt or a weak model produces confident nonsense. Read
|
|
263
291
|
`opening_hook` and `red_flags` against the source before quoting anything.
|
|
264
292
|
- Redaction is regex-based and will miss names and identifiers without a fixed shape.
|
|
265
|
-
-
|
|
293
|
+
- Duplicate folding ignores formatting only; a single changed digit keeps a copy separate.
|
|
294
|
+
- Type slugs are keyword-matched, so an unusual description falls to `other` rather than inventing one.
|
|
266
295
|
- The cost cap depends on provider-reported usage and can overshoot by the in-flight count.
|
|
267
296
|
- Analysis is English-centric: the prompt and schema assume English artifacts.
|
|
268
297
|
- Quote checking compares text, not meaning. A legitimate paraphrase left in quotes is flagged;
|
|
@@ -97,7 +97,8 @@ corpus is at [`docs/sample-report.md`](https://github.com/sjdsjadjajdjsa/scamscr
|
|
|
97
97
|
| `prune` | Delete old logs |
|
|
98
98
|
|
|
99
99
|
Useful flags: `--dry-run`, `--json`, `--redact`, `--top N`, `--recursive`, `--timeout`,
|
|
100
|
-
`--retries`, `--max-tokens`, `--concurrency`, `--max-cost`, `--no-
|
|
100
|
+
`--retries`, `--max-tokens`, `--concurrency`, `--max-cost`, `--dedupe/--no-dedupe`,
|
|
101
|
+
`--show-duplicates`, `--type`, `--no-color`. `--json` puts one object per script on stdout and all
|
|
101
102
|
Rich output on stderr, so it pipes:
|
|
102
103
|
|
|
103
104
|
```bash
|
|
@@ -118,6 +119,33 @@ The cap reads `usage.cost` from the response, so it works only where the provide
|
|
|
118
119
|
says so when none is sent. Spend is printed and logged per run. In-flight requests can overshoot by
|
|
119
120
|
up to the concurrency, so keep headroom on a hard limit.
|
|
120
121
|
|
|
122
|
+
## Attack types
|
|
123
|
+
|
|
124
|
+
`attack_type` is free prose, which is useful to read and useless to group. Every result also carries a
|
|
125
|
+
closed-set slug (`vishing`, `smishing`, `tech-support`, `refund`, `gov-impersonation`, `bec`,
|
|
126
|
+
`romance`, `crypto`, `investment`, `utility`, `delivery`, `other`). Reports show counts and mean scores
|
|
127
|
+
per slug above the ranking, and `--type` filters to the ones you ask for:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
scamscript batch corpus/ --type tech-support,refund --output report.md
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The raw phrase is always kept next to the slug. An unrecognised description lands in `other` and the
|
|
134
|
+
CLI names any filter token it did not recognise.
|
|
135
|
+
|
|
136
|
+
## Duplicates
|
|
137
|
+
|
|
138
|
+
Corpora are full of reprints. Each script is fingerprinted on its body: headers, casing, punctuation
|
|
139
|
+
and whitespace are ignored, so copies collapse. The strongest copy is ranked and the rest are listed
|
|
140
|
+
under it; the report counts what was folded and JSON records carry `fingerprint` and `duplicate_of`.
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
scamscript batch corpus/ --output report.md # folds by default
|
|
144
|
+
scamscript batch corpus/ --no-dedupe --show-duplicates
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Only byte-level twins fold. A copy with one changed account number stays separate, by design.
|
|
148
|
+
|
|
121
149
|
## How it works
|
|
122
150
|
|
|
123
151
|
```text
|
|
@@ -154,7 +182,7 @@ masking is best effort: names, case IDs and spelled-out digits survive it.
|
|
|
154
182
|
Every request appends to a timestamped JSONL file under `~/.scamscript/logs/` (override with
|
|
155
183
|
`--home` or `SCAMSCRIPT_HOME`). The API key is never written. Logs are the source for `report`, so
|
|
156
184
|
a run replays without spending tokens, and `prune --keep 200 --keep-days 30` keeps the store
|
|
157
|
-
bounded.
|
|
185
|
+
bounded. Directories are `0700` and log files `0600`.
|
|
158
186
|
|
|
159
187
|
## Install and configure
|
|
160
188
|
|
|
@@ -226,7 +254,8 @@ docs/ calibration record, sample report, banner, demo
|
|
|
226
254
|
- Breakdowns are model readings; a bad prompt or a weak model produces confident nonsense. Read
|
|
227
255
|
`opening_hook` and `red_flags` against the source before quoting anything.
|
|
228
256
|
- Redaction is regex-based and will miss names and identifiers without a fixed shape.
|
|
229
|
-
-
|
|
257
|
+
- Duplicate folding ignores formatting only; a single changed digit keeps a copy separate.
|
|
258
|
+
- Type slugs are keyword-matched, so an unusual description falls to `other` rather than inventing one.
|
|
230
259
|
- The cost cap depends on provider-reported usage and can overshoot by the in-flight count.
|
|
231
260
|
- Analysis is English-centric: the prompt and schema assume English artifacts.
|
|
232
261
|
- Quote checking compares text, not meaning. A legitimate paraphrase left in quotes is flagged;
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "scamscript"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.6.0"
|
|
8
8
|
description = "Reverse-engineer scam scripts and vishing transcripts into structured counter-playbooks"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -7,7 +7,7 @@ from dataclasses import dataclass
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
10
|
-
from scamscript import prompts, redact, verify
|
|
10
|
+
from scamscript import dedupe, prompts, redact, taxonomy, verify
|
|
11
11
|
from scamscript.client import ChatClient, ClientError, TruncatedError
|
|
12
12
|
from scamscript.ingest import ScriptFile
|
|
13
13
|
from scamscript.models import BreakdownError, ScriptBreakdown, parse_breakdown
|
|
@@ -27,6 +27,9 @@ class Outcome:
|
|
|
27
27
|
duration: float = 0.0
|
|
28
28
|
redactions: dict[str, int] | None = None
|
|
29
29
|
skipped: bool = False
|
|
30
|
+
fingerprint: str = ""
|
|
31
|
+
attack_slug: str = ""
|
|
32
|
+
duplicate_of: str | None = None
|
|
30
33
|
issues: list[verify.Issue] | None = None
|
|
31
34
|
verification: dict[str, int] | None = None
|
|
32
35
|
|
|
@@ -41,6 +44,9 @@ class Outcome:
|
|
|
41
44
|
"name": self.name,
|
|
42
45
|
"ok": self.ok,
|
|
43
46
|
"skipped": self.skipped,
|
|
47
|
+
"fingerprint": self.fingerprint,
|
|
48
|
+
"attack_slug": self.attack_slug,
|
|
49
|
+
"duplicate_of": self.duplicate_of,
|
|
44
50
|
"attempts": self.attempts,
|
|
45
51
|
"duration_s": round(self.duration, 3),
|
|
46
52
|
"breakdown": self.breakdown.model_dump() if self.breakdown else None,
|
|
@@ -63,7 +69,9 @@ def analyze_script(
|
|
|
63
69
|
redact_identifiers: bool = False,
|
|
64
70
|
verify_quotes: bool = True,
|
|
65
71
|
) -> Outcome:
|
|
66
|
-
outcome = Outcome(
|
|
72
|
+
outcome = Outcome(
|
|
73
|
+
source=script.path, name=script.name, fingerprint=dedupe.fingerprint(script.text)
|
|
74
|
+
)
|
|
67
75
|
started = time.monotonic()
|
|
68
76
|
|
|
69
77
|
text = script.text
|
|
@@ -113,6 +121,7 @@ def analyze_script(
|
|
|
113
121
|
try:
|
|
114
122
|
outcome.breakdown = parse_breakdown(visible)
|
|
115
123
|
outcome.error = None
|
|
124
|
+
outcome.attack_slug = taxonomy.slugify(outcome.breakdown.attack_type)
|
|
116
125
|
if verify_quotes:
|
|
117
126
|
outcome.issues = verify.verify_breakdown(outcome.breakdown, text)
|
|
118
127
|
outcome.verification = verify.summarize(outcome.issues)
|
|
@@ -12,7 +12,7 @@ import typer
|
|
|
12
12
|
from rich.console import Console
|
|
13
13
|
from rich.table import Table
|
|
14
14
|
|
|
15
|
-
from scamscript import __version__, prompts, redact, store
|
|
15
|
+
from scamscript import __version__, prompts, redact, store, taxonomy
|
|
16
16
|
from scamscript import report as reporting
|
|
17
17
|
from scamscript.analysis import Outcome, analyze_script
|
|
18
18
|
from scamscript.client import ChatClient
|
|
@@ -52,6 +52,8 @@ class Context:
|
|
|
52
52
|
json_output: bool
|
|
53
53
|
verify_quotes: bool
|
|
54
54
|
strict_quotes: bool
|
|
55
|
+
dedupe: bool
|
|
56
|
+
show_duplicates: bool
|
|
55
57
|
console: Console
|
|
56
58
|
err_console: Console
|
|
57
59
|
|
|
@@ -135,6 +137,16 @@ def main(
|
|
|
135
137
|
"--strict-quotes",
|
|
136
138
|
help="Exit non-zero when a quoted claim is not found in the source.",
|
|
137
139
|
),
|
|
140
|
+
dedupe: bool = typer.Option(
|
|
141
|
+
True,
|
|
142
|
+
"--dedupe/--no-dedupe",
|
|
143
|
+
help="Fold identical scripts into the strongest copy in reports.",
|
|
144
|
+
),
|
|
145
|
+
show_duplicates: bool = typer.Option(
|
|
146
|
+
False,
|
|
147
|
+
"--show-duplicates",
|
|
148
|
+
help="Render a full panel for duplicate copies too.",
|
|
149
|
+
),
|
|
138
150
|
dry_run: bool = typer.Option(
|
|
139
151
|
False,
|
|
140
152
|
"--dry-run",
|
|
@@ -173,6 +185,8 @@ def main(
|
|
|
173
185
|
json_output=json_output,
|
|
174
186
|
verify_quotes=verify_quotes,
|
|
175
187
|
strict_quotes=strict_quotes,
|
|
188
|
+
dedupe=dedupe,
|
|
189
|
+
show_duplicates=show_duplicates,
|
|
176
190
|
console=Console(
|
|
177
191
|
no_color=no_color,
|
|
178
192
|
highlight=False,
|
|
@@ -220,6 +234,7 @@ def _run(
|
|
|
220
234
|
batch_mode: bool,
|
|
221
235
|
output: Path | None,
|
|
222
236
|
top: int | None,
|
|
237
|
+
types: list[str] | None = None,
|
|
223
238
|
) -> None:
|
|
224
239
|
console = state.console
|
|
225
240
|
settings = state.settings
|
|
@@ -278,6 +293,7 @@ def _run(
|
|
|
278
293
|
spent = 0.0
|
|
279
294
|
spent_seen = False
|
|
280
295
|
lock = threading.Lock()
|
|
296
|
+
seen: dict[str, str] = {}
|
|
281
297
|
client = ChatClient(
|
|
282
298
|
api_key=api_key,
|
|
283
299
|
base_url=settings.base_url,
|
|
@@ -308,6 +324,11 @@ def _run(
|
|
|
308
324
|
redact_identifiers=settings.redact,
|
|
309
325
|
verify_quotes=state.verify_quotes,
|
|
310
326
|
)
|
|
327
|
+
with lock:
|
|
328
|
+
if state.dedupe and outcome.fingerprint:
|
|
329
|
+
outcome.duplicate_of = seen.setdefault(outcome.fingerprint, outcome.name)
|
|
330
|
+
if outcome.duplicate_of == outcome.name:
|
|
331
|
+
outcome.duplicate_of = None
|
|
311
332
|
cost = client.last_cost
|
|
312
333
|
if cost:
|
|
313
334
|
with lock:
|
|
@@ -320,6 +341,10 @@ def _run(
|
|
|
320
341
|
_emit_json(outcome.as_record())
|
|
321
342
|
elif outcome.skipped:
|
|
322
343
|
console.print(f"[dim]skipped {outcome.name}: budget reached[/dim]")
|
|
344
|
+
elif outcome.duplicate_of and not state.show_duplicates:
|
|
345
|
+
console.print(
|
|
346
|
+
f"[dim]{outcome.name}: duplicate of {outcome.duplicate_of}[/dim]"
|
|
347
|
+
)
|
|
323
348
|
elif outcome.breakdown is not None:
|
|
324
349
|
console.print(
|
|
325
350
|
render_breakdown(
|
|
@@ -388,14 +413,24 @@ def _run(
|
|
|
388
413
|
cost_usd=round(spent, 6),
|
|
389
414
|
)
|
|
390
415
|
|
|
416
|
+
only_types = taxonomy.parse_filter(types)
|
|
417
|
+
if only_types:
|
|
418
|
+
unknown = taxonomy.unknown(types)
|
|
419
|
+
if unknown:
|
|
420
|
+
console.print(
|
|
421
|
+
f"[yellow]note[/yellow] unknown type(s): {', '.join(sorted(unknown))}"
|
|
422
|
+
)
|
|
423
|
+
collapsed = reporting.collapse(report_entries) if state.dedupe else report_entries
|
|
424
|
+
|
|
391
425
|
if batch_mode and (successes or skipped):
|
|
392
426
|
if not state.json_output:
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
)
|
|
427
|
+
rows = (
|
|
428
|
+
[(e.name, e.breakdown, e.error, e.skipped) for e in collapsed]
|
|
429
|
+
if state.dedupe
|
|
430
|
+
else [(o.name, o.breakdown, o.error, o.skipped) for o in finished]
|
|
397
431
|
)
|
|
398
|
-
|
|
432
|
+
console.print(render_summary_table(rows))
|
|
433
|
+
ranked = reporting.rank(collapsed)
|
|
399
434
|
if top:
|
|
400
435
|
ranked = ranked[:top]
|
|
401
436
|
markdown = reporting.build_markdown(
|
|
@@ -403,6 +438,7 @@ def _run(
|
|
|
403
438
|
run_id=logger.run_id,
|
|
404
439
|
command=command,
|
|
405
440
|
model=settings.model,
|
|
441
|
+
only_types=only_types,
|
|
406
442
|
)
|
|
407
443
|
written = _emit_report(console, markdown, output, json_mode=state.json_output)
|
|
408
444
|
if state.json_output:
|
|
@@ -469,6 +505,9 @@ def analyze(
|
|
|
469
505
|
),
|
|
470
506
|
recursive: bool = typer.Option(False, "--recursive", "-r", help="Recurse into subdirectories."),
|
|
471
507
|
top: int | None = typer.Option(None, "--top", min=1, help="Keep only the N most sophisticated scripts."),
|
|
508
|
+
types: list[str] | None = typer.Option(
|
|
509
|
+
None, "--type", help="Only report these type slugs (repeatable or comma-separated)."
|
|
510
|
+
),
|
|
472
511
|
) -> None:
|
|
473
512
|
"""Analyze a single script file, or a directory with --batch."""
|
|
474
513
|
state = _context(ctx)
|
|
@@ -481,7 +520,15 @@ def analyze(
|
|
|
481
520
|
except IngestError as exc:
|
|
482
521
|
_fail(state.console, str(exc))
|
|
483
522
|
return
|
|
484
|
-
_run(
|
|
523
|
+
_run(
|
|
524
|
+
state,
|
|
525
|
+
scripts,
|
|
526
|
+
command="analyze",
|
|
527
|
+
batch_mode=batch_mode,
|
|
528
|
+
output=output,
|
|
529
|
+
top=top,
|
|
530
|
+
types=types,
|
|
531
|
+
)
|
|
485
532
|
|
|
486
533
|
|
|
487
534
|
@app.command()
|
|
@@ -493,6 +540,9 @@ def batch(
|
|
|
493
540
|
),
|
|
494
541
|
recursive: bool = typer.Option(False, "--recursive", "-r", help="Recurse into subdirectories."),
|
|
495
542
|
top: int | None = typer.Option(None, "--top", min=1, help="Keep only the N most sophisticated scripts."),
|
|
543
|
+
types: list[str] | None = typer.Option(
|
|
544
|
+
None, "--type", help="Only report these type slugs (repeatable or comma-separated)."
|
|
545
|
+
),
|
|
496
546
|
) -> None:
|
|
497
547
|
"""Analyze a directory and rank every script by sophistication."""
|
|
498
548
|
state = _context(ctx)
|
|
@@ -501,7 +551,7 @@ def batch(
|
|
|
501
551
|
except IngestError as exc:
|
|
502
552
|
_fail(state.console, str(exc))
|
|
503
553
|
return
|
|
504
|
-
_run(state, scripts, command="batch", batch_mode=True, output=output, top=top)
|
|
554
|
+
_run(state, scripts, command="batch", batch_mode=True, output=output, top=top, types=types)
|
|
505
555
|
|
|
506
556
|
|
|
507
557
|
@app.command()
|
|
@@ -512,6 +562,9 @@ def report( # noqa: A001
|
|
|
512
562
|
),
|
|
513
563
|
output: Path | None = typer.Option(None, "--output", "-o", help="Write the markdown here."),
|
|
514
564
|
logs_dir: Path | None = typer.Option(None, "--logs-dir", help="Override the log directory."),
|
|
565
|
+
types: list[str] | None = typer.Option(
|
|
566
|
+
None, "--type", help="Only report these type slugs (repeatable or comma-separated)."
|
|
567
|
+
),
|
|
515
568
|
) -> None:
|
|
516
569
|
"""Regenerate the aggregated report from a past run's log."""
|
|
517
570
|
state = _context(ctx)
|
|
@@ -538,6 +591,7 @@ def report( # noqa: A001
|
|
|
538
591
|
run_id=str(run["run_id"]),
|
|
539
592
|
command=str(run.get("command") or "report"),
|
|
540
593
|
model=run.get("model"),
|
|
594
|
+
only_types=taxonomy.parse_filter(types),
|
|
541
595
|
)
|
|
542
596
|
_emit_report(state.console, markdown, output)
|
|
543
597
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Content fingerprints for collapsing reprinted scripts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
NON_WORD = re.compile(r"[^a-z0-9]+")
|
|
9
|
+
HEADER_LINE = re.compile(r"^\s*#")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def body(text: str) -> str:
|
|
13
|
+
"""Drop the leading comment block a captured script usually carries."""
|
|
14
|
+
lines = text.splitlines()
|
|
15
|
+
index = 0
|
|
16
|
+
while index < len(lines) and (not lines[index].strip() or HEADER_LINE.match(lines[index])):
|
|
17
|
+
index += 1
|
|
18
|
+
return "\n".join(lines[index:])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def normalize(text: str) -> str:
|
|
22
|
+
lowered = body(text).lower()
|
|
23
|
+
return " ".join(NON_WORD.sub(" ", lowered).split())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def fingerprint(text: str) -> str:
|
|
27
|
+
"""Short hash of the normalized body, stable across whitespace and punctuation."""
|
|
28
|
+
normalized = normalize(text)
|
|
29
|
+
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16]
|
|
@@ -9,6 +9,7 @@ from rich.table import Table
|
|
|
9
9
|
from rich.text import Text
|
|
10
10
|
|
|
11
11
|
from scamscript.ingest import ScriptFile
|
|
12
|
+
from scamscript import taxonomy
|
|
12
13
|
from scamscript.models import ScriptBreakdown
|
|
13
14
|
from scamscript.verify import Issue, summarize
|
|
14
15
|
|
|
@@ -123,6 +124,7 @@ def render_breakdown(
|
|
|
123
124
|
style=f"bold {border_for(breakdown)}",
|
|
124
125
|
)
|
|
125
126
|
renderables.append(attack)
|
|
127
|
+
renderables.append(Text(f"slug: {taxonomy.slugify(breakdown.attack_type)}", style="dim"))
|
|
126
128
|
renderables.append(Text(""))
|
|
127
129
|
|
|
128
130
|
for key in (
|
|
@@ -7,6 +7,7 @@ from typing import Any
|
|
|
7
7
|
|
|
8
8
|
from pydantic import ValidationError
|
|
9
9
|
|
|
10
|
+
from scamscript import taxonomy
|
|
10
11
|
from scamscript.models import ScriptBreakdown
|
|
11
12
|
from scamscript.store import results_of, utc_now
|
|
12
13
|
|
|
@@ -30,6 +31,9 @@ class ReportEntry:
|
|
|
30
31
|
error: str | None = None
|
|
31
32
|
duration: float = 0.0
|
|
32
33
|
skipped: bool = False
|
|
34
|
+
fingerprint: str = ""
|
|
35
|
+
attack_slug: str = ""
|
|
36
|
+
duplicates: list[str] | None = None
|
|
33
37
|
verification: dict[str, int] | None = None
|
|
34
38
|
unverified: list[dict[str, str]] | None = None
|
|
35
39
|
|
|
@@ -44,6 +48,8 @@ def entry_from_outcome(outcome: Any) -> ReportEntry:
|
|
|
44
48
|
source=str(getattr(outcome, "source", outcome.name)),
|
|
45
49
|
breakdown=outcome.breakdown,
|
|
46
50
|
skipped=getattr(outcome, "skipped", False),
|
|
51
|
+
fingerprint=getattr(outcome, "fingerprint", ""),
|
|
52
|
+
attack_slug=getattr(outcome, "attack_slug", ""),
|
|
47
53
|
error=outcome.error,
|
|
48
54
|
duration=getattr(outcome, "duration", 0.0),
|
|
49
55
|
verification=getattr(outcome, "verification", None),
|
|
@@ -71,6 +77,9 @@ def entries_from_run(run: dict[str, Any]) -> list[ReportEntry]:
|
|
|
71
77
|
source=record.get("source") or "",
|
|
72
78
|
breakdown=breakdown,
|
|
73
79
|
skipped=bool(record.get("skipped")),
|
|
80
|
+
fingerprint=record.get("fingerprint") or "",
|
|
81
|
+
attack_slug=record.get("attack_slug") or "",
|
|
82
|
+
duplicates=record.get("duplicates") or None,
|
|
74
83
|
error=None if breakdown else record.get("error") or "no breakdown stored",
|
|
75
84
|
duration=float(record.get("duration_s") or 0.0),
|
|
76
85
|
verification=record.get("verification") or None,
|
|
@@ -80,6 +89,29 @@ def entries_from_run(run: dict[str, Any]) -> list[ReportEntry]:
|
|
|
80
89
|
return entries
|
|
81
90
|
|
|
82
91
|
|
|
92
|
+
def collapse(entries: list[ReportEntry]) -> list[ReportEntry]:
|
|
93
|
+
"""Keep the strongest copy of each fingerprint and list the rest under it."""
|
|
94
|
+
groups: dict[str, list[ReportEntry]] = {}
|
|
95
|
+
for entry in entries:
|
|
96
|
+
if entry.ok and entry.fingerprint:
|
|
97
|
+
groups.setdefault(entry.fingerprint, []).append(entry)
|
|
98
|
+
|
|
99
|
+
primaries: dict[str, ReportEntry] = {}
|
|
100
|
+
for key, group in groups.items():
|
|
101
|
+
if len(group) > 1:
|
|
102
|
+
best = max(group, key=lambda item: (item.breakdown.sophistication_score, item.breakdown.flag_count))
|
|
103
|
+
best.duplicates = [item.name for item in group if item is not best]
|
|
104
|
+
primaries[key] = best
|
|
105
|
+
else:
|
|
106
|
+
primaries[key] = group[0]
|
|
107
|
+
|
|
108
|
+
return [
|
|
109
|
+
primaries[entry.fingerprint] if entry.ok and entry.fingerprint else entry
|
|
110
|
+
for entry in entries
|
|
111
|
+
if not (entry.ok and entry.fingerprint and primaries[entry.fingerprint] is not entry)
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
|
|
83
115
|
def rank(entries: list[ReportEntry]) -> list[ReportEntry]:
|
|
84
116
|
return sorted(
|
|
85
117
|
entries,
|
|
@@ -96,7 +128,14 @@ def build_markdown(
|
|
|
96
128
|
run_id: str,
|
|
97
129
|
command: str = "batch",
|
|
98
130
|
model: str | None = None,
|
|
131
|
+
only_types: set[str] | None = None,
|
|
99
132
|
) -> str:
|
|
133
|
+
if only_types:
|
|
134
|
+
entries = [
|
|
135
|
+
entry
|
|
136
|
+
for entry in entries
|
|
137
|
+
if not entry.ok or (entry.attack_slug or taxonomy.slugify(entry.breakdown.attack_type)) in only_types
|
|
138
|
+
]
|
|
100
139
|
ranked = rank(entries)
|
|
101
140
|
ok = [entry for entry in ranked if entry.ok]
|
|
102
141
|
skipped = [entry for entry in ranked if entry.skipped and not entry.ok]
|
|
@@ -118,6 +157,9 @@ def build_markdown(
|
|
|
118
157
|
lines.append(f"- Failed: {len(failed)}")
|
|
119
158
|
if skipped:
|
|
120
159
|
lines.append(f"- Skipped at budget: {len(skipped)}")
|
|
160
|
+
folded = sum(len(entry.duplicates or []) for entry in ok)
|
|
161
|
+
if folded:
|
|
162
|
+
lines.append(f"- Duplicate copies folded: {folded}")
|
|
121
163
|
unverified_total = sum(len(entry.unverified or []) for entry in ok)
|
|
122
164
|
if unverified_total:
|
|
123
165
|
lines.append(f"- Unverified quotes: {unverified_total}")
|
|
@@ -126,15 +168,23 @@ def build_markdown(
|
|
|
126
168
|
lines.append("")
|
|
127
169
|
|
|
128
170
|
if ok:
|
|
171
|
+
lines.append("## Attack types")
|
|
172
|
+
lines.append("")
|
|
173
|
+
lines.append("| Type | Scripts | Mean score |")
|
|
174
|
+
lines.append("| --- | ---: | ---: |")
|
|
175
|
+
for slug, count, mean in _type_counts(ok):
|
|
176
|
+
lines.append(f"| {taxonomy.label(slug)} (`{slug}`) | {count} | {mean:.1f} |")
|
|
177
|
+
lines.append("")
|
|
129
178
|
lines.append("## Ranking by sophistication")
|
|
130
179
|
lines.append("")
|
|
131
|
-
lines.append("| Rank | Script |
|
|
132
|
-
lines.append("| ---: | --- | --- | ---: | ---: | ---: |")
|
|
180
|
+
lines.append("| Rank | Script | Type | Sophistication | Red flags | Unverified | Copies |")
|
|
181
|
+
lines.append("| ---: | --- | --- | ---: | ---: | ---: | ---: |")
|
|
133
182
|
for position, entry in enumerate(ok, start=1):
|
|
134
183
|
lines.append(
|
|
135
|
-
f"| {position} | {_cell(entry.name)} |
|
|
184
|
+
f"| {position} | {_cell(entry.name)} | "
|
|
185
|
+
f"`{entry.attack_slug or taxonomy.slugify(entry.breakdown.attack_type)}` | "
|
|
136
186
|
f"{entry.breakdown.sophistication_score}/10 | {entry.breakdown.flag_count} | "
|
|
137
|
-
f"{len(entry.unverified or [])} |"
|
|
187
|
+
f"{len(entry.unverified or [])} | {1 + len(entry.duplicates or [])} |"
|
|
138
188
|
)
|
|
139
189
|
lines.append("")
|
|
140
190
|
for position, entry in enumerate(ok, start=1):
|
|
@@ -172,8 +222,13 @@ def _detail_section(position: int, entry: ReportEntry) -> list[str]:
|
|
|
172
222
|
assert breakdown is not None
|
|
173
223
|
lines = [f"## {position}. {entry.name}", ""]
|
|
174
224
|
lines.append(f"- **Attack type:** {breakdown.attack_type}")
|
|
225
|
+
lines.append(
|
|
226
|
+
f"- **Type slug:** `{entry.attack_slug or taxonomy.slugify(breakdown.attack_type)}`"
|
|
227
|
+
)
|
|
175
228
|
lines.append(f"- **Sophistication:** {breakdown.sophistication_score}/10")
|
|
176
229
|
lines.append(f"- **Red flags observed:** {breakdown.flag_count}")
|
|
230
|
+
if entry.duplicates:
|
|
231
|
+
lines.append(f"- **Identical copies folded:** {len(entry.duplicates)} ({', '.join(_cell(name) for name in entry.duplicates)})")
|
|
177
232
|
counts = entry.verification or {}
|
|
178
233
|
if counts.get("checked"):
|
|
179
234
|
lines.append(
|
|
@@ -205,5 +260,19 @@ def _detail_section(position: int, entry: ReportEntry) -> list[str]:
|
|
|
205
260
|
return lines
|
|
206
261
|
|
|
207
262
|
|
|
263
|
+
def _type_counts(entries: list[ReportEntry]) -> list[tuple[str, int, float]]:
|
|
264
|
+
buckets: dict[str, list[int]] = {}
|
|
265
|
+
for entry in entries:
|
|
266
|
+
if not entry.breakdown:
|
|
267
|
+
continue
|
|
268
|
+
slug = entry.attack_slug or taxonomy.slugify(entry.breakdown.attack_type)
|
|
269
|
+
buckets.setdefault(slug, []).append(entry.breakdown.sophistication_score)
|
|
270
|
+
rows = [
|
|
271
|
+
(slug, len(scores), sum(scores) / len(scores))
|
|
272
|
+
for slug, scores in buckets.items()
|
|
273
|
+
]
|
|
274
|
+
return sorted(rows, key=lambda row: (-row[1], row[0]))
|
|
275
|
+
|
|
276
|
+
|
|
208
277
|
def _cell(value: str) -> str:
|
|
209
278
|
return (value or "").replace("|", "\\|").replace("\n", " ").strip()
|
|
@@ -31,14 +31,23 @@ class RunLogger:
|
|
|
31
31
|
self.command = command
|
|
32
32
|
self.path = self.logs_dir / f"{self.run_id}.jsonl"
|
|
33
33
|
self._started = False
|
|
34
|
+
self._locked_down = False
|
|
35
|
+
|
|
36
|
+
def _prepare(self) -> None:
|
|
37
|
+
self.logs_dir.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
for directory in (self.logs_dir.parent, self.logs_dir):
|
|
39
|
+
_chmod(directory, 0o700)
|
|
34
40
|
|
|
35
41
|
def _write(self, record: dict[str, Any]) -> None:
|
|
36
42
|
record.setdefault("ts", utc_now())
|
|
37
43
|
record.setdefault("run_id", self.run_id)
|
|
38
44
|
try:
|
|
39
|
-
self.
|
|
45
|
+
self._prepare()
|
|
40
46
|
with self.path.open("a", encoding="utf-8") as handle:
|
|
41
47
|
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
48
|
+
if not self._locked_down:
|
|
49
|
+
self._locked_down = True
|
|
50
|
+
_chmod(self.path, 0o600)
|
|
42
51
|
except OSError:
|
|
43
52
|
pass
|
|
44
53
|
|
|
@@ -145,9 +154,18 @@ def _mtime(path: Path) -> float:
|
|
|
145
154
|
return 0.0
|
|
146
155
|
|
|
147
156
|
|
|
157
|
+
def _chmod(path: Path, mode: int) -> None:
|
|
158
|
+
try:
|
|
159
|
+
os.chmod(path, mode)
|
|
160
|
+
except OSError:
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
|
|
148
164
|
def ensure_home(home: str) -> None:
|
|
149
165
|
try:
|
|
150
166
|
os.makedirs(os.path.join(home, "logs"), exist_ok=True)
|
|
167
|
+
_chmod(Path(home), 0o700)
|
|
168
|
+
_chmod(Path(home) / "logs", 0o700)
|
|
151
169
|
except OSError:
|
|
152
170
|
pass
|
|
153
171
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Map free-text attack descriptions onto a fixed set of slugs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
OTHER = "other"
|
|
8
|
+
|
|
9
|
+
# Checked in order: the earlier, more specific family wins.
|
|
10
|
+
RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
11
|
+
("bec", ("business email", "bec", "invoice fraud", "vendor payment", "mandate fraud", "ceo fraud")),
|
|
12
|
+
("tech-support", ("tech support", "tech-support", "remote access", "windows security", "computer support")),
|
|
13
|
+
("refund", ("refund", "overpayment", "rebate")),
|
|
14
|
+
("gov-impersonation", ("government", "tax", "revenue", "irs", "hmrc", "dvla", "council", "police", "court")),
|
|
15
|
+
("utility", ("utility", "water board", "energy", "electric", "gas board", "boiler", "home service")),
|
|
16
|
+
("delivery", ("parcel", "delivery", "courier", "postage", "shipping")),
|
|
17
|
+
("crypto", ("crypto", "bitcoin", "wallet", "token")),
|
|
18
|
+
("investment", ("investment", "trading", "broker", "forex", "pig butchering")),
|
|
19
|
+
("romance", ("romance", "dating", "long con", "long-con", "sweetheart")),
|
|
20
|
+
("smishing", ("smishing", "sms", "text message", "text-message", "whatsapp")),
|
|
21
|
+
("vishing", ("vishing", "phone call", "cold call", "robocall", "phone")),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
SLUGS = tuple(slug for slug, _ in RULES) + (OTHER,)
|
|
25
|
+
NON_WORD = re.compile(r"[^a-z0-9]+")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def slugify(attack_type: str) -> str:
|
|
29
|
+
"""Return a canonical slug for a model-written attack description."""
|
|
30
|
+
text = NON_WORD.sub(" ", (attack_type or "").lower())
|
|
31
|
+
for slug, needles in RULES:
|
|
32
|
+
if any(needle in text for needle in needles):
|
|
33
|
+
return slug
|
|
34
|
+
return OTHER
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def unknown(values: list[str] | None) -> set[str]:
|
|
38
|
+
"""Raw filter tokens that are not a known slug."""
|
|
39
|
+
if not values:
|
|
40
|
+
return set()
|
|
41
|
+
out: set[str] = set()
|
|
42
|
+
for value in values:
|
|
43
|
+
for chunk in value.split(","):
|
|
44
|
+
token = chunk.strip().lower()
|
|
45
|
+
if token and token not in SLUGS:
|
|
46
|
+
out.add(token)
|
|
47
|
+
return out
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def label(slug: str) -> str:
|
|
51
|
+
return {
|
|
52
|
+
"bec": "Business email compromise",
|
|
53
|
+
"tech-support": "Tech support",
|
|
54
|
+
"refund": "Refund",
|
|
55
|
+
"gov-impersonation": "Government impersonation",
|
|
56
|
+
"utility": "Utility",
|
|
57
|
+
"delivery": "Delivery",
|
|
58
|
+
"crypto": "Crypto",
|
|
59
|
+
"investment": "Investment",
|
|
60
|
+
"romance": "Romance",
|
|
61
|
+
"smishing": "Smishing",
|
|
62
|
+
"vishing": "Vishing",
|
|
63
|
+
"other": "Other",
|
|
64
|
+
}.get(slug, slug)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_filter(values: list[str] | None) -> set[str]:
|
|
68
|
+
"""Turn a comma-separated --type option into a slug set."""
|
|
69
|
+
if not values:
|
|
70
|
+
return set()
|
|
71
|
+
out: set[str] = set()
|
|
72
|
+
for value in values:
|
|
73
|
+
for chunk in value.split(","):
|
|
74
|
+
slug = chunk.strip().lower()
|
|
75
|
+
if slug:
|
|
76
|
+
out.add(slugify(slug))
|
|
77
|
+
return out
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# second copy, reformatted
|
|
2
|
+
|
|
3
|
+
# captured 2026-10-02, first copy
|
|
4
|
+
|
|
5
|
+
Caller — Good morning, this is the card security desk.
|
|
6
|
+
|
|
7
|
+
Caller — We have blocked two transactions on your card ending 4417!
|
|
8
|
+
|
|
9
|
+
Caller — Read me the last four digits and we will release the block.
|
|
10
|
+
|
|
11
|
+
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# near twin: one extra sentence, different card digits
|
|
2
|
+
CALLER: Good morning, this is the card security desk.
|
|
3
|
+
CALLER: We have blocked two transactions on your card ending 9902.
|
|
4
|
+
CALLER: Read me the last four digits and we will release the block.
|
|
5
|
+
CALLER: If we do not clear it now, the block stays until you visit a branch.
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
2
4
|
|
|
3
5
|
import pytest
|
|
4
6
|
from typer.testing import CliRunner
|
|
@@ -228,7 +230,7 @@ def test_dry_run_without_endpoint(home, tmp_path, monkeypatch):
|
|
|
228
230
|
def test_version(home):
|
|
229
231
|
result = runner.invoke(cli.app, ["--version"])
|
|
230
232
|
assert result.exit_code == 0
|
|
231
|
-
assert result.stdout.strip() == "scamscript 0.
|
|
233
|
+
assert result.stdout.strip() == "scamscript 0.6.0"
|
|
232
234
|
|
|
233
235
|
|
|
234
236
|
def test_json_output_is_one_object_per_script(home, tmp_path, fake):
|
|
@@ -409,3 +411,70 @@ def test_max_cost_warns_when_provider_reports_nothing(home, tmp_path, fake):
|
|
|
409
411
|
)
|
|
410
412
|
assert result.exit_code == 0
|
|
411
413
|
assert "could not be enforced" in result.stdout
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _reprints(tmp_path):
|
|
417
|
+
src = Path(__file__).parent / "fixtures" / "reprints"
|
|
418
|
+
for item in src.iterdir():
|
|
419
|
+
(tmp_path / item.name).write_text(item.read_text(), encoding="utf-8")
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def test_dedupe_folds_reprints(home, tmp_path, fake):
|
|
423
|
+
_reprints(tmp_path)
|
|
424
|
+
scores = {"original": 4, "copy-punctuation": 9, "copy-header": 6, "near-twin": 8}
|
|
425
|
+
for key, score in scores.items():
|
|
426
|
+
fake.replies[key] = breakdown(score=score)
|
|
427
|
+
out = tmp_path / "r.md"
|
|
428
|
+
result = runner.invoke(cli.app, ["--api-key", "k", "batch", str(tmp_path), "--output", str(out)])
|
|
429
|
+
assert result.exit_code == 0
|
|
430
|
+
markdown = out.read_text()
|
|
431
|
+
assert "Duplicate copies folded: 2" in markdown
|
|
432
|
+
assert len(re.findall(r"^## \d+\. ", markdown, re.M)) == 2
|
|
433
|
+
assert "| 1 | copy-punctuation.txt |" in markdown
|
|
434
|
+
assert "near-twin.txt" in markdown
|
|
435
|
+
assert "copy-header.txt" in markdown
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def test_no_dedupe_keeps_every_copy(home, tmp_path, fake):
|
|
439
|
+
_reprints(tmp_path)
|
|
440
|
+
out = tmp_path / "r.md"
|
|
441
|
+
result = runner.invoke(
|
|
442
|
+
cli.app, ["--api-key", "k", "--no-dedupe", "batch", str(tmp_path), "--output", str(out)]
|
|
443
|
+
)
|
|
444
|
+
assert result.exit_code == 0
|
|
445
|
+
text = out.read_text()
|
|
446
|
+
assert "Duplicate copies folded" not in text
|
|
447
|
+
assert len(re.findall(r"^\| \d+ \|", text, re.M)) == 4
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def test_json_marks_duplicates(home, tmp_path, fake):
|
|
451
|
+
_reprints(tmp_path)
|
|
452
|
+
result = runner.invoke(cli.app, ["--api-key", "k", "--json", "batch", str(tmp_path)])
|
|
453
|
+
assert result.exit_code == 0
|
|
454
|
+
records = [json.loads(line) for line in result.stdout.splitlines() if line.strip()]
|
|
455
|
+
results = [record for record in records if record.get("event") == "result"]
|
|
456
|
+
assert all(record["fingerprint"] for record in results)
|
|
457
|
+
assert sum(1 for record in results if record["duplicate_of"]) == 2
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def test_type_filter_limits_report(home, tmp_path, fake):
|
|
461
|
+
(tmp_path / "a.txt").write_text("CALLER: vish", encoding="utf-8")
|
|
462
|
+
(tmp_path / "b.txt").write_text("CALLER: romance", encoding="utf-8")
|
|
463
|
+
fake.replies = {"vish": breakdown(attack="vishing"), "romance": breakdown(attack="romance scam")}
|
|
464
|
+
out = tmp_path / "r.md"
|
|
465
|
+
result = runner.invoke(
|
|
466
|
+
cli.app, ["--api-key", "k", "batch", str(tmp_path), "--type", "vishing", "--output", str(out)]
|
|
467
|
+
)
|
|
468
|
+
assert result.exit_code == 0
|
|
469
|
+
text = out.read_text()
|
|
470
|
+
assert "a.txt" in text
|
|
471
|
+
assert "b.txt" not in text
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def test_unknown_type_warns(home, tmp_path, fake):
|
|
475
|
+
(tmp_path / "a.txt").write_text("CALLER: x", encoding="utf-8")
|
|
476
|
+
result = runner.invoke(
|
|
477
|
+
cli.app, ["--api-key", "k", "batch", str(tmp_path), "--type", "wibble"]
|
|
478
|
+
)
|
|
479
|
+
assert result.exit_code == 0
|
|
480
|
+
assert "unknown type" in result.stdout
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from scamscript.dedupe import fingerprint, normalize
|
|
4
|
+
from scamscript.report import ReportEntry, collapse
|
|
5
|
+
|
|
6
|
+
FIXTURES = Path(__file__).parent / "fixtures" / "reprints"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def read(name):
|
|
10
|
+
return (FIXTURES / name).read_text()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_header_and_punctuation_ignored():
|
|
14
|
+
assert fingerprint(read("original.txt")) == fingerprint(read("copy-punctuation.txt"))
|
|
15
|
+
assert fingerprint(read("original.txt")) == fingerprint(read("copy-header.txt"))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_near_twin_differs():
|
|
19
|
+
assert fingerprint(read("original.txt")) != fingerprint(read("near-twin.txt"))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_normalize_collapses_whitespace():
|
|
23
|
+
assert normalize("a b\n\tc") == "a b c"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_empty_and_header_only_stable():
|
|
27
|
+
assert fingerprint("") == fingerprint("# only a header\n")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def entry(name, score, fp="abc"):
|
|
31
|
+
from scamscript.models import ScriptBreakdown
|
|
32
|
+
|
|
33
|
+
breakdown = ScriptBreakdown(
|
|
34
|
+
attack_type="vishing",
|
|
35
|
+
persona="p",
|
|
36
|
+
opening_hook="h",
|
|
37
|
+
trust_building_moves=["a"],
|
|
38
|
+
urgency_tactics=["b"],
|
|
39
|
+
escalation_ladder=["c"],
|
|
40
|
+
target_demographics="d",
|
|
41
|
+
red_flags=["e"],
|
|
42
|
+
defense_summary="f",
|
|
43
|
+
sophistication_score=score,
|
|
44
|
+
)
|
|
45
|
+
return ReportEntry(name=name, source=name, breakdown=breakdown, fingerprint=fp)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_collapse_keeps_strongest_and_lists_rest():
|
|
49
|
+
entries = [entry("a", 4), entry("b", 9), entry("c", 6)]
|
|
50
|
+
collapsed = collapse(entries)
|
|
51
|
+
assert len(collapsed) == 1
|
|
52
|
+
assert collapsed[0].name == "b"
|
|
53
|
+
assert collapsed[0].duplicates == ["a", "c"]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_collapse_leaves_distinct_and_failed_alone():
|
|
57
|
+
entries = [entry("a", 5), entry("twin", 8, fp="zzz"), ReportEntry(name="bad", source="bad")]
|
|
58
|
+
collapsed = collapse(entries)
|
|
59
|
+
assert [item.name for item in collapsed] == ["a", "twin", "bad"]
|
|
60
|
+
assert collapsed[0].duplicates is None
|
|
@@ -31,10 +31,11 @@ def test_markdown_has_summary_and_details():
|
|
|
31
31
|
]
|
|
32
32
|
markdown = build_markdown(entries, run_id="run-1", command="batch", model="m")
|
|
33
33
|
assert "# Scam script breakdown report" in markdown
|
|
34
|
-
assert "| 1 | a.txt | vishing | 9/10 | 1 |" in markdown
|
|
34
|
+
assert "| 1 | a.txt | `vishing` | 9/10 | 1 | 0 | 1 |" in markdown
|
|
35
35
|
assert "## Failures" in markdown
|
|
36
36
|
assert "bad json" in markdown
|
|
37
37
|
assert "### Defense summary" in markdown
|
|
38
|
+
assert "## Attack types" in markdown
|
|
38
39
|
assert "security research and fraud-awareness" in markdown
|
|
39
40
|
|
|
40
41
|
|
|
@@ -77,3 +78,15 @@ def test_report_shows_unverified_quotes():
|
|
|
77
78
|
assert "**Quote check:** 1/2 verbatim, 1 missing" in markdown
|
|
78
79
|
assert "### Unverified quotes" in markdown
|
|
79
80
|
assert "_missing_ [red flags] \"invented\"" in markdown
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_type_filter_keeps_only_allowed_and_failures():
|
|
84
|
+
entries = [
|
|
85
|
+
ReportEntry(name="a.txt", source="a", breakdown=breakdown(9, attack="vishing"), attack_slug="vishing"),
|
|
86
|
+
ReportEntry(name="b.txt", source="b", breakdown=breakdown(5, attack="romance scam"), attack_slug="romance"),
|
|
87
|
+
ReportEntry(name="bad.txt", source="bad", error="boom"),
|
|
88
|
+
]
|
|
89
|
+
markdown = build_markdown(entries, run_id="r", only_types={"vishing"})
|
|
90
|
+
assert "a.txt" in markdown
|
|
91
|
+
assert "b.txt" not in markdown
|
|
92
|
+
assert "bad.txt" in markdown
|
|
@@ -38,3 +38,13 @@ def test_prune_by_age(tmp_path):
|
|
|
38
38
|
|
|
39
39
|
def test_prune_missing_dir(tmp_path):
|
|
40
40
|
assert prune(str(tmp_path / "ghost")) == (0, 0, 0)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_log_dir_and_file_are_private(tmp_path):
|
|
44
|
+
from scamscript.store import RunLogger
|
|
45
|
+
|
|
46
|
+
log = RunLogger(str(tmp_path / "home" / "logs"), "analyze")
|
|
47
|
+
log.run_start(model="m")
|
|
48
|
+
assert oct((tmp_path / "home").stat().st_mode)[-3:] == "700"
|
|
49
|
+
assert oct((tmp_path / "home" / "logs").stat().st_mode)[-3:] == "700"
|
|
50
|
+
assert oct(log.path.stat().st_mode)[-3:] == "600"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from scamscript.taxonomy import SLUGS, label, parse_filter, slugify
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@pytest.mark.parametrize(
|
|
7
|
+
"text,slug",
|
|
8
|
+
[
|
|
9
|
+
("government impersonation vishing (tax/revenue)", "gov-impersonation"),
|
|
10
|
+
("vishing (automated robocall payment scam)", "vishing"),
|
|
11
|
+
("romance scam / smishing long con", "romance"),
|
|
12
|
+
("parcel delivery fee scam", "delivery"),
|
|
13
|
+
("tech support scam with remote access", "tech-support"),
|
|
14
|
+
("business email compromise by phone", "bec"),
|
|
15
|
+
("crypto investment", "crypto"),
|
|
16
|
+
("pig butchering investment scam", "investment"),
|
|
17
|
+
("smishing text message", "smishing"),
|
|
18
|
+
("utility impersonation", "utility"),
|
|
19
|
+
("something entirely new", "other"),
|
|
20
|
+
("", "other"),
|
|
21
|
+
],
|
|
22
|
+
)
|
|
23
|
+
def test_mapping(text, slug):
|
|
24
|
+
assert slugify(text) == slug
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_bec_wins_over_vishing():
|
|
28
|
+
assert slugify("vishing-led wire fraud, business email compromise") == "bec"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_case_and_punctuation_insensitive():
|
|
32
|
+
assert slugify("TECH-SUPPORT, remote access!") == "tech-support"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_slugs_are_closed_set():
|
|
36
|
+
assert "other" in SLUGS and "vishing" in SLUGS
|
|
37
|
+
assert slugify("nonsense") in SLUGS
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_parse_filter_accepts_comma_and_spaces():
|
|
41
|
+
assert parse_filter(["tech support,romance", "vishing"]) == {
|
|
42
|
+
"tech-support",
|
|
43
|
+
"romance",
|
|
44
|
+
"vishing",
|
|
45
|
+
}
|
|
46
|
+
assert parse_filter(None) == set()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_label_known_and_fallback():
|
|
50
|
+
assert label("bec") == "Business email compromise"
|
|
51
|
+
assert label("odd") == "odd"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|