scamscript 0.7.0__tar.gz → 0.8.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.7.0 → scamscript-0.8.0}/CHANGELOG.md +13 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/PKG-INFO +23 -4
- {scamscript-0.7.0 → scamscript-0.8.0}/README.md +22 -3
- {scamscript-0.7.0 → scamscript-0.8.0}/docs/real-corpus.md +7 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/pyproject.toml +1 -1
- {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/__init__.py +1 -1
- {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/analysis.py +7 -0
- scamscript-0.8.0/src/scamscript/brief.py +178 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/cli.py +44 -1
- {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/client.py +1 -1
- {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/models.py +17 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/prompts.py +2 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/render.py +1 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/report.py +10 -2
- {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/taxonomy.py +16 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/tests/test_brief.py +19 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/tests/test_cli.py +52 -8
- {scamscript-0.7.0 → scamscript-0.8.0}/tests/test_report.py +10 -1
- {scamscript-0.7.0 → scamscript-0.8.0}/tests/test_taxonomy.py +22 -0
- scamscript-0.7.0/src/scamscript/brief.py +0 -95
- {scamscript-0.7.0 → scamscript-0.8.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/.github/SECURITY.md +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/.github/pull_request_template.md +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/.github/workflows/ci.yml +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/.github/workflows/release.yml +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/.gitignore +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/CITATION.cff +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/CODE_OF_CONDUCT.md +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/CONTRIBUTING.md +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/LICENSE +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/corpus/real/real-commonwealth-bank-card-vishing.txt +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/corpus/real/real-credit-union-card-vishing.txt +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/corpus/real/real-ftc-apple-support-robocall.txt +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/corpus/real/real-ftc-interest-rate-robocall.txt +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/corpus/real/real-ftc-irs-settlement-robocall.txt +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/corpus/real/real-ftc-ssa-suspension-robocall.txt +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/corpus/real/real-ftc-student-loan-robocall.txt +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/corpus/real/real-ftc-utility-rebate-robocall.txt +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/corpus/real/real-medicare-benefits-vishing.txt +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/corpus/real/real-usps-invalid-zip-smishing.txt +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/docs/banner.svg +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/docs/calibration.md +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/docs/demo.gif +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/docs/sample-report.md +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/docs/social-preview.png +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/examples/adversarial/fake_output_json.txt +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/examples/adversarial/fence_escape.txt +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/examples/adversarial/fixed_attack_type.txt +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/examples/adversarial/non_json_demand.txt +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/examples/adversarial/print_system_prompt.txt +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/examples/adversarial/role_switch.txt +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/examples/adversarial/score_override.txt +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/examples/crude_robocall.txt +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/examples/gov_impersonation_vishing.txt +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/examples/prompt_injection_script.txt +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/examples/romance_scam_sms.txt +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/examples/sophisticated_wire_fraud.txt +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/examples/tech_support_scam.txt +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/scripts/calibrate.py +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/scripts/injection_eval.py +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/__main__.py +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/config.py +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/dedupe.py +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/ingest.py +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/language.py +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/redact.py +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/store.py +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/src/scamscript/verify.py +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/tests/fixtures/reprints/copy-header.txt +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/tests/fixtures/reprints/copy-punctuation.txt +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/tests/fixtures/reprints/near-twin.txt +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/tests/fixtures/reprints/original.txt +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/tests/test_analysis.py +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/tests/test_client.py +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/tests/test_dedupe.py +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/tests/test_ingest.py +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/tests/test_language.py +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/tests/test_models.py +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/tests/test_prompts.py +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/tests/test_redact.py +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/tests/test_render.py +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/tests/test_store.py +0 -0
- {scamscript-0.7.0 → scamscript-0.8.0}/tests/test_verify.py +0 -0
|
@@ -5,6 +5,19 @@ schema are the things that count as public API.
|
|
|
5
5
|
|
|
6
6
|
## Unreleased
|
|
7
7
|
|
|
8
|
+
## 0.8.0 - 2026-11-02
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `channel` field (`vishing`, `smishing`, `email`, `other`) separate from the attack theme, with
|
|
13
|
+
`--channel` filtering in reports and briefings.
|
|
14
|
+
- `brief --html`: self-contained printable page, escaped and offline.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- Panels show the channel; JSON records carry it; real-corpus validation confirms text lures keep
|
|
19
|
+
their delivery route.
|
|
20
|
+
|
|
8
21
|
## 0.7.0 - 2026-11-02
|
|
9
22
|
|
|
10
23
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: scamscript
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.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
|
|
@@ -111,7 +111,7 @@ Each script renders as a colour-coded panel:
|
|
|
111
111
|
╰────────────────────────────────── 7 red flags ───────────────────────────────╯
|
|
112
112
|
```
|
|
113
113
|
|
|
114
|
-
Fields: `attack_type`, `persona`, `opening_hook`, `trust_building_moves`, `urgency_tactics`,
|
|
114
|
+
Fields: `attack_type`, `channel`, `persona`, `opening_hook`, `trust_building_moves`, `urgency_tactics`,
|
|
115
115
|
`escalation_ladder`, `target_demographics`, `red_flags`, `defense_summary`, `sophistication_score`.
|
|
116
116
|
|
|
117
117
|
The panel also carries a **quote check**: every quoted claim in `opening_hook` and `red_flags` is
|
|
@@ -135,7 +135,7 @@ corpus is at [`docs/sample-report.md`](https://github.com/sjdsjadjajdjsa/scamscr
|
|
|
135
135
|
|
|
136
136
|
Useful flags: `--dry-run`, `--json`, `--redact`, `--top N`, `--recursive`, `--timeout`,
|
|
137
137
|
`--retries`, `--max-tokens`, `--concurrency`, `--max-cost`, `--dedupe/--no-dedupe`,
|
|
138
|
-
`--show-duplicates`, `--type`, `--language`, `--force`, `--no-color`. `--json` puts one object per script on stdout and all
|
|
138
|
+
`--show-duplicates`, `--type`, `--channel`, `--language`, `--force`, `--no-color`. `--json` puts one object per script on stdout and all
|
|
139
139
|
Rich output on stderr, so it pipes:
|
|
140
140
|
|
|
141
141
|
```bash
|
|
@@ -178,7 +178,15 @@ per slug above the ranking, and `--type` filters to the ones you ask for:
|
|
|
178
178
|
scamscript batch corpus/ --type tech-support,refund --output report.md
|
|
179
179
|
```
|
|
180
180
|
|
|
181
|
-
|
|
181
|
+
Delivery is tracked separately from theme. `channel` is one of `vishing`, `smishing`, `email` or
|
|
182
|
+
`other`, so a delivery-themed text lure is `delivery` + `smishing` rather than losing one of the two.
|
|
183
|
+
`--channel` filters on it:
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
scamscript batch corpus/real --channel smishing --output texts.md
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Briefings group by type but accept `--channel` too. Build one from a finished run:
|
|
182
190
|
|
|
183
191
|
```bash
|
|
184
192
|
scamscript brief --type tech-support --output briefing.md
|
|
@@ -238,6 +246,15 @@ Every request appends to a timestamped JSONL file under `~/.scamscript/logs/` (o
|
|
|
238
246
|
a run replays without spending tokens, and `prune --keep 200 --keep-days 30` keeps the store
|
|
239
247
|
bounded. Directories are `0700` and log files `0600`.
|
|
240
248
|
|
|
249
|
+
## Printable briefings
|
|
250
|
+
|
|
251
|
+
`brief --html` writes a self-contained page with inline CSS and escaped text, for handing to people
|
|
252
|
+
who will not open a terminal.
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
scamscript brief --type tech-support --html --output handout.html
|
|
256
|
+
```
|
|
257
|
+
|
|
241
258
|
## Paths
|
|
242
259
|
|
|
243
260
|
`--file` and `--dir` resolve symlinks, a recursive walk survives a symlink loop, and a file that
|
|
@@ -318,6 +335,8 @@ docs/ calibration record, sample report, banner, demo
|
|
|
318
335
|
- Duplicate folding ignores formatting only; a single changed digit keeps a copy separate.
|
|
319
336
|
- Type slugs are keyword-matched, so an unusual description falls to `other` rather than inventing one.
|
|
320
337
|
- The cost cap depends on provider-reported usage and can overshoot by the in-flight count.
|
|
338
|
+
- `channel` is inferred from the model's answer or, failing that, from keywords; a script with no
|
|
339
|
+
channel words falls to `other`.
|
|
321
340
|
- The prompt asks the model to answer in the script's own language and `--language` overrides
|
|
322
341
|
detection, but the type keywords are English: a non-English script often falls to `other`.
|
|
323
342
|
- Quote checking compares text, not meaning. A legitimate paraphrase left in quotes is flagged;
|
|
@@ -75,7 +75,7 @@ Each script renders as a colour-coded panel:
|
|
|
75
75
|
╰────────────────────────────────── 7 red flags ───────────────────────────────╯
|
|
76
76
|
```
|
|
77
77
|
|
|
78
|
-
Fields: `attack_type`, `persona`, `opening_hook`, `trust_building_moves`, `urgency_tactics`,
|
|
78
|
+
Fields: `attack_type`, `channel`, `persona`, `opening_hook`, `trust_building_moves`, `urgency_tactics`,
|
|
79
79
|
`escalation_ladder`, `target_demographics`, `red_flags`, `defense_summary`, `sophistication_score`.
|
|
80
80
|
|
|
81
81
|
The panel also carries a **quote check**: every quoted claim in `opening_hook` and `red_flags` is
|
|
@@ -99,7 +99,7 @@ corpus is at [`docs/sample-report.md`](https://github.com/sjdsjadjajdjsa/scamscr
|
|
|
99
99
|
|
|
100
100
|
Useful flags: `--dry-run`, `--json`, `--redact`, `--top N`, `--recursive`, `--timeout`,
|
|
101
101
|
`--retries`, `--max-tokens`, `--concurrency`, `--max-cost`, `--dedupe/--no-dedupe`,
|
|
102
|
-
`--show-duplicates`, `--type`, `--language`, `--force`, `--no-color`. `--json` puts one object per script on stdout and all
|
|
102
|
+
`--show-duplicates`, `--type`, `--channel`, `--language`, `--force`, `--no-color`. `--json` puts one object per script on stdout and all
|
|
103
103
|
Rich output on stderr, so it pipes:
|
|
104
104
|
|
|
105
105
|
```bash
|
|
@@ -142,7 +142,15 @@ per slug above the ranking, and `--type` filters to the ones you ask for:
|
|
|
142
142
|
scamscript batch corpus/ --type tech-support,refund --output report.md
|
|
143
143
|
```
|
|
144
144
|
|
|
145
|
-
|
|
145
|
+
Delivery is tracked separately from theme. `channel` is one of `vishing`, `smishing`, `email` or
|
|
146
|
+
`other`, so a delivery-themed text lure is `delivery` + `smishing` rather than losing one of the two.
|
|
147
|
+
`--channel` filters on it:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
scamscript batch corpus/real --channel smishing --output texts.md
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Briefings group by type but accept `--channel` too. Build one from a finished run:
|
|
146
154
|
|
|
147
155
|
```bash
|
|
148
156
|
scamscript brief --type tech-support --output briefing.md
|
|
@@ -202,6 +210,15 @@ Every request appends to a timestamped JSONL file under `~/.scamscript/logs/` (o
|
|
|
202
210
|
a run replays without spending tokens, and `prune --keep 200 --keep-days 30` keeps the store
|
|
203
211
|
bounded. Directories are `0700` and log files `0600`.
|
|
204
212
|
|
|
213
|
+
## Printable briefings
|
|
214
|
+
|
|
215
|
+
`brief --html` writes a self-contained page with inline CSS and escaped text, for handing to people
|
|
216
|
+
who will not open a terminal.
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
scamscript brief --type tech-support --html --output handout.html
|
|
220
|
+
```
|
|
221
|
+
|
|
205
222
|
## Paths
|
|
206
223
|
|
|
207
224
|
`--file` and `--dir` resolve symlinks, a recursive walk survives a symlink loop, and a file that
|
|
@@ -282,6 +299,8 @@ docs/ calibration record, sample report, banner, demo
|
|
|
282
299
|
- Duplicate folding ignores formatting only; a single changed digit keeps a copy separate.
|
|
283
300
|
- Type slugs are keyword-matched, so an unusual description falls to `other` rather than inventing one.
|
|
284
301
|
- The cost cap depends on provider-reported usage and can overshoot by the in-flight count.
|
|
302
|
+
- `channel` is inferred from the model's answer or, failing that, from keywords; a script with no
|
|
303
|
+
channel words falls to `other`.
|
|
285
304
|
- The prompt asks the model to answer in the script's own language and `--language` overrides
|
|
286
305
|
detection, but the type keywords are English: a non-English script often falls to `other`.
|
|
287
306
|
- Quote checking compares text, not meaning. A legitimate paraphrase left in quotes is flagged;
|
|
@@ -35,3 +35,10 @@ Ten files, `--concurrency 3 --redact`, 2026-11-02, primary model:
|
|
|
35
35
|
the model described the pattern instead of quoting a line.
|
|
36
36
|
|
|
37
37
|
Recorded cost for the full directory: about two cents.
|
|
38
|
+
|
|
39
|
+
## Channel split, same day
|
|
40
|
+
|
|
41
|
+
A third run added the `channel` field. All nine call transcripts type as `vishing`; the USPS message
|
|
42
|
+
is `delivery` + `smishing` instead of one erasing the other. The Medicare call again needed one
|
|
43
|
+
repair, and the bank call produced three paraphrased quotes this time against zero the run before:
|
|
44
|
+
quote drift varies by draw, which is why the checker flags rather than rewrites.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "scamscript"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.8.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"
|
|
@@ -29,6 +29,7 @@ class Outcome:
|
|
|
29
29
|
skipped: bool = False
|
|
30
30
|
fingerprint: str = ""
|
|
31
31
|
attack_slug: str = ""
|
|
32
|
+
channel: str = "other"
|
|
32
33
|
language: str = "unknown"
|
|
33
34
|
duplicate_of: str | None = None
|
|
34
35
|
issues: list[verify.Issue] | None = None
|
|
@@ -47,6 +48,7 @@ class Outcome:
|
|
|
47
48
|
"skipped": self.skipped,
|
|
48
49
|
"fingerprint": self.fingerprint,
|
|
49
50
|
"attack_slug": self.attack_slug,
|
|
51
|
+
"channel": self.channel,
|
|
50
52
|
"language": self.language,
|
|
51
53
|
"duplicate_of": self.duplicate_of,
|
|
52
54
|
"attempts": self.attempts,
|
|
@@ -130,6 +132,11 @@ def analyze_script(
|
|
|
130
132
|
outcome.breakdown = parse_breakdown(visible)
|
|
131
133
|
outcome.error = None
|
|
132
134
|
outcome.attack_slug = taxonomy.slugify(outcome.breakdown.attack_type)
|
|
135
|
+
outcome.channel = (
|
|
136
|
+
outcome.breakdown.channel
|
|
137
|
+
if outcome.breakdown.channel != "other"
|
|
138
|
+
else taxonomy.channel_of(outcome.breakdown.attack_type, outcome.breakdown.opening_hook)
|
|
139
|
+
)
|
|
133
140
|
if verify_quotes:
|
|
134
141
|
outcome.issues = verify.verify_breakdown(outcome.breakdown, text)
|
|
135
142
|
outcome.verification = verify.summarize(outcome.issues)
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Turn a recorded run into a one-page defence briefing for trainers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from html import escape
|
|
6
|
+
|
|
7
|
+
from scamscript import taxonomy
|
|
8
|
+
from scamscript.report import ReportEntry
|
|
9
|
+
|
|
10
|
+
MAX_FLAGS = 6
|
|
11
|
+
MAX_PRESSURE = 4
|
|
12
|
+
MAX_COUNTERS = 4
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _unique(values: list[str], limit: int) -> list[str]:
|
|
16
|
+
seen: set[str] = set()
|
|
17
|
+
out: list[str] = []
|
|
18
|
+
for value in values:
|
|
19
|
+
key = value.strip().lower()
|
|
20
|
+
if not key or key in seen:
|
|
21
|
+
continue
|
|
22
|
+
seen.add(key)
|
|
23
|
+
out.append(value.strip())
|
|
24
|
+
if len(out) >= limit:
|
|
25
|
+
break
|
|
26
|
+
return out
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def groups_for(
|
|
30
|
+
entries: list[ReportEntry],
|
|
31
|
+
only_types: set[str] | None = None,
|
|
32
|
+
only_channels: set[str] | None = None,
|
|
33
|
+
) -> dict[str, list[ReportEntry]]:
|
|
34
|
+
groups: dict[str, list[ReportEntry]] = {}
|
|
35
|
+
for entry in entries:
|
|
36
|
+
if not entry.ok or not entry.breakdown:
|
|
37
|
+
continue
|
|
38
|
+
slug = entry.attack_slug or taxonomy.slugify(entry.breakdown.attack_type)
|
|
39
|
+
if only_types and slug not in only_types:
|
|
40
|
+
continue
|
|
41
|
+
if only_channels and entry.channel not in only_channels:
|
|
42
|
+
continue
|
|
43
|
+
groups.setdefault(slug, []).append(entry)
|
|
44
|
+
return groups
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _parts(group: list[ReportEntry]) -> tuple[list[str], list[str], list[str]]:
|
|
48
|
+
flags = _unique([flag for entry in group for flag in entry.breakdown.red_flags], MAX_FLAGS)
|
|
49
|
+
pressure = _unique(
|
|
50
|
+
[
|
|
51
|
+
item
|
|
52
|
+
for entry in group
|
|
53
|
+
for item in entry.breakdown.urgency_tactics + entry.breakdown.escalation_ladder
|
|
54
|
+
],
|
|
55
|
+
MAX_PRESSURE,
|
|
56
|
+
)
|
|
57
|
+
counters = _unique([entry.breakdown.defense_summary for entry in group], MAX_COUNTERS)
|
|
58
|
+
return flags, pressure, counters
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build_brief(
|
|
62
|
+
entries: list[ReportEntry],
|
|
63
|
+
run_id: str,
|
|
64
|
+
only_types: set[str] | None = None,
|
|
65
|
+
only_channels: set[str] | None = None,
|
|
66
|
+
) -> str:
|
|
67
|
+
groups = groups_for(entries, only_types, only_channels)
|
|
68
|
+
|
|
69
|
+
lines = ["# Scam defence briefing", ""]
|
|
70
|
+
lines.append(f"Built from run `{run_id}`. Every claim comes from the analyzed scripts.")
|
|
71
|
+
lines.append("")
|
|
72
|
+
if not groups:
|
|
73
|
+
lines.append("_No matching successful analyses in this run._")
|
|
74
|
+
return "\n".join(lines) + "\n"
|
|
75
|
+
|
|
76
|
+
lines.append("Use this in order: the flags are what a target can notice, the counters are what")
|
|
77
|
+
lines.append("they should do instead of complying.")
|
|
78
|
+
lines.append("")
|
|
79
|
+
|
|
80
|
+
ordered = sorted(groups.items(), key=lambda item: (-len(item[1]), item[0]))
|
|
81
|
+
for slug, group in ordered:
|
|
82
|
+
scores = [entry.breakdown.sophistication_score for entry in group]
|
|
83
|
+
flags, pressure, counters = _parts(group)
|
|
84
|
+
|
|
85
|
+
lines.append(f"## {taxonomy.label(slug)} (`{slug}`)")
|
|
86
|
+
lines.append("")
|
|
87
|
+
lines.append(f"{len(group)} script(s), mean sophistication {sum(scores) / len(scores):.1f}/10.")
|
|
88
|
+
lines.append("")
|
|
89
|
+
lines.append("### What to notice")
|
|
90
|
+
lines.append("")
|
|
91
|
+
lines.extend(f"- {flag}" for flag in flags or ["_Nothing recorded._"])
|
|
92
|
+
lines.append("")
|
|
93
|
+
if pressure:
|
|
94
|
+
lines.append("### How pressure is applied")
|
|
95
|
+
lines.append("")
|
|
96
|
+
lines.extend(f"- {item}" for item in pressure)
|
|
97
|
+
lines.append("")
|
|
98
|
+
lines.append("### What to do")
|
|
99
|
+
lines.append("")
|
|
100
|
+
lines.extend(f"- {counter}" for counter in counters or ["_Nothing recorded._"])
|
|
101
|
+
lines.append("")
|
|
102
|
+
lines.append("### Sources")
|
|
103
|
+
lines.append("")
|
|
104
|
+
lines.extend(f"- `{entry.name}`" for entry in group)
|
|
105
|
+
lines.append("")
|
|
106
|
+
|
|
107
|
+
lines.append("---")
|
|
108
|
+
lines.append("")
|
|
109
|
+
lines.append(
|
|
110
|
+
"_Model-produced reading of captured scripts, for security research and fraud-awareness "
|
|
111
|
+
"training only._"
|
|
112
|
+
)
|
|
113
|
+
lines.append("")
|
|
114
|
+
return "\n".join(lines)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
STYLE = """
|
|
118
|
+
:root { color-scheme: light dark; }
|
|
119
|
+
body { font: 16px/1.55 system-ui, sans-serif; max-width: 46rem; margin: 2rem auto; padding: 0 1rem; }
|
|
120
|
+
h1 { font-size: 1.6rem; } h2 { font-size: 1.2rem; margin-top: 2rem; border-top: 1px solid #8884; padding-top: 1rem; }
|
|
121
|
+
h3 { font-size: .95rem; text-transform: uppercase; letter-spacing: .04em; margin-bottom: .3rem; }
|
|
122
|
+
ul { margin-top: .2rem; } .meta { color: #777; } .slug { font-family: ui-monospace, monospace; color: #777; }
|
|
123
|
+
footer { margin-top: 2rem; color: #777; font-size: .85rem; }
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def build_brief_html(
|
|
128
|
+
entries: list[ReportEntry],
|
|
129
|
+
run_id: str,
|
|
130
|
+
only_types: set[str] | None = None,
|
|
131
|
+
only_channels: set[str] | None = None,
|
|
132
|
+
) -> str:
|
|
133
|
+
groups = groups_for(entries, only_types, only_channels)
|
|
134
|
+
out = [
|
|
135
|
+
"<!DOCTYPE html>",
|
|
136
|
+
'<html lang="en"><head><meta charset="utf-8">',
|
|
137
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
138
|
+
"<title>Scam defence briefing</title>",
|
|
139
|
+
f"<style>{STYLE}</style></head><body>",
|
|
140
|
+
"<h1>Scam defence briefing</h1>",
|
|
141
|
+
f'<p class="meta">Built from run <code>{escape(run_id)}</code>. '
|
|
142
|
+
"Every claim comes from the analyzed scripts.</p>",
|
|
143
|
+
]
|
|
144
|
+
if not groups:
|
|
145
|
+
out.append("<p><em>No matching successful analyses in this run.</em></p>")
|
|
146
|
+
else:
|
|
147
|
+
out.append(
|
|
148
|
+
"<p>The flags are what a target can notice. The counters are what to do instead of "
|
|
149
|
+
"complying.</p>"
|
|
150
|
+
)
|
|
151
|
+
for slug, group in sorted(groups.items(), key=lambda item: (-len(item[1]), item[0])):
|
|
152
|
+
scores = [entry.breakdown.sophistication_score for entry in group]
|
|
153
|
+
flags, pressure, counters = _parts(group)
|
|
154
|
+
out.append("<section>")
|
|
155
|
+
out.append(
|
|
156
|
+
f"<h2>{escape(taxonomy.label(slug))} <span class=\"slug\">({escape(slug)})</span></h2>"
|
|
157
|
+
)
|
|
158
|
+
out.append(
|
|
159
|
+
f'<p class="meta">{len(group)} script(s), mean sophistication '
|
|
160
|
+
f"{sum(scores) / len(scores):.1f}/10.</p>"
|
|
161
|
+
)
|
|
162
|
+
out.append("<h3>What to notice</h3><ul>")
|
|
163
|
+
out.extend(f"<li>{escape(flag)}</li>" for flag in flags or ["Nothing recorded."])
|
|
164
|
+
out.append("</ul>")
|
|
165
|
+
if pressure:
|
|
166
|
+
out.append("<h3>How pressure is applied</h3><ul>")
|
|
167
|
+
out.extend(f"<li>{escape(item)}</li>" for item in pressure)
|
|
168
|
+
out.append("</ul>")
|
|
169
|
+
out.append("<h3>What to do</h3><ul>")
|
|
170
|
+
out.extend(f"<li>{escape(counter)}</li>" for counter in counters or ["Nothing recorded."])
|
|
171
|
+
out.append("</ul><h3>Sources</h3><ul>")
|
|
172
|
+
out.extend(f"<li><code>{escape(entry.name)}</code></li>" for entry in group)
|
|
173
|
+
out.append("</ul></section>")
|
|
174
|
+
out.append(
|
|
175
|
+
"<footer>Model-produced reading of captured scripts. Security research and "
|
|
176
|
+
"fraud-awareness training only.</footer></body></html>"
|
|
177
|
+
)
|
|
178
|
+
return "\n".join(out) + "\n"
|
|
@@ -241,6 +241,7 @@ def _run(
|
|
|
241
241
|
output: Path | None,
|
|
242
242
|
top: int | None,
|
|
243
243
|
types: list[str] | None = None,
|
|
244
|
+
channels: list[str] | None = None,
|
|
244
245
|
force: bool = False,
|
|
245
246
|
) -> None:
|
|
246
247
|
console = state.console
|
|
@@ -429,6 +430,17 @@ def _run(
|
|
|
429
430
|
console.print(
|
|
430
431
|
f"[yellow]note[/yellow] unknown type(s): {', '.join(sorted(unknown))}"
|
|
431
432
|
)
|
|
433
|
+
only_channels = {
|
|
434
|
+
chunk.strip().lower()
|
|
435
|
+
for value in channels or []
|
|
436
|
+
for chunk in value.split(",")
|
|
437
|
+
if chunk.strip()
|
|
438
|
+
}
|
|
439
|
+
invalid_channels = only_channels - set(taxonomy.CHANNELS)
|
|
440
|
+
if invalid_channels:
|
|
441
|
+
console.print(
|
|
442
|
+
f"[yellow]note[/yellow] unknown channel(s): {', '.join(sorted(invalid_channels))}"
|
|
443
|
+
)
|
|
432
444
|
collapsed = reporting.collapse(report_entries) if state.dedupe else report_entries
|
|
433
445
|
|
|
434
446
|
if batch_mode and (successes or skipped):
|
|
@@ -448,6 +460,7 @@ def _run(
|
|
|
448
460
|
command=command,
|
|
449
461
|
model=settings.model,
|
|
450
462
|
only_types=only_types,
|
|
463
|
+
only_channels=only_channels,
|
|
451
464
|
)
|
|
452
465
|
written = _emit_report(
|
|
453
466
|
console, markdown, output, json_mode=state.json_output, force=force
|
|
@@ -527,6 +540,9 @@ def analyze(
|
|
|
527
540
|
None, "--type", help="Only report these type slugs (repeatable or comma-separated)."
|
|
528
541
|
),
|
|
529
542
|
force: bool = typer.Option(False, "--force", help="Overwrite an existing output file."),
|
|
543
|
+
channels: list[str] | None = typer.Option(
|
|
544
|
+
None, "--channel", help="Only report these channels (vishing, smishing, email, other)."
|
|
545
|
+
),
|
|
530
546
|
) -> None:
|
|
531
547
|
"""Analyze a single script file, or a directory with --batch."""
|
|
532
548
|
state = _context(ctx)
|
|
@@ -547,6 +563,7 @@ def analyze(
|
|
|
547
563
|
output=output,
|
|
548
564
|
top=top,
|
|
549
565
|
types=types,
|
|
566
|
+
channels=channels,
|
|
550
567
|
force=force,
|
|
551
568
|
)
|
|
552
569
|
|
|
@@ -564,6 +581,9 @@ def batch(
|
|
|
564
581
|
None, "--type", help="Only report these type slugs (repeatable or comma-separated)."
|
|
565
582
|
),
|
|
566
583
|
force: bool = typer.Option(False, "--force", help="Overwrite an existing output file."),
|
|
584
|
+
channels: list[str] | None = typer.Option(
|
|
585
|
+
None, "--channel", help="Only report these channels (vishing, smishing, email, other)."
|
|
586
|
+
),
|
|
567
587
|
) -> None:
|
|
568
588
|
"""Analyze a directory and rank every script by sophistication."""
|
|
569
589
|
state = _context(ctx)
|
|
@@ -580,6 +600,7 @@ def batch(
|
|
|
580
600
|
output=output,
|
|
581
601
|
top=top,
|
|
582
602
|
types=types,
|
|
603
|
+
channels=channels,
|
|
583
604
|
force=force,
|
|
584
605
|
)
|
|
585
606
|
|
|
@@ -596,6 +617,9 @@ def report( # noqa: A001
|
|
|
596
617
|
None, "--type", help="Only report these type slugs (repeatable or comma-separated)."
|
|
597
618
|
),
|
|
598
619
|
force: bool = typer.Option(False, "--force", help="Overwrite an existing output file."),
|
|
620
|
+
channels: list[str] | None = typer.Option(
|
|
621
|
+
None, "--channel", help="Only report these channels (vishing, smishing, email, other)."
|
|
622
|
+
),
|
|
599
623
|
) -> None:
|
|
600
624
|
"""Regenerate the aggregated report from a past run's log."""
|
|
601
625
|
state = _context(ctx)
|
|
@@ -623,6 +647,12 @@ def report( # noqa: A001
|
|
|
623
647
|
command=str(run.get("command") or "report"),
|
|
624
648
|
model=run.get("model"),
|
|
625
649
|
only_types=taxonomy.parse_filter(types),
|
|
650
|
+
only_channels={
|
|
651
|
+
chunk.strip().lower()
|
|
652
|
+
for value in channels or []
|
|
653
|
+
for chunk in value.split(",")
|
|
654
|
+
if chunk.strip()
|
|
655
|
+
},
|
|
626
656
|
)
|
|
627
657
|
_emit_report(state.console, markdown, output, force=force)
|
|
628
658
|
|
|
@@ -673,9 +703,15 @@ def brief(
|
|
|
673
703
|
types: list[str] | None = typer.Option(
|
|
674
704
|
None, "--type", help="Only brief these type slugs (repeatable or comma-separated)."
|
|
675
705
|
),
|
|
706
|
+
channels: list[str] | None = typer.Option(
|
|
707
|
+
None, "--channel", help="Only brief these channels (vishing, smishing, email, other)."
|
|
708
|
+
),
|
|
676
709
|
output: Path | None = typer.Option(None, "--output", "-o", help="Write the briefing here."),
|
|
677
710
|
logs_dir: Path | None = typer.Option(None, "--logs-dir", help="Override the log directory."),
|
|
678
711
|
force: bool = typer.Option(False, "--force", help="Overwrite an existing output file."),
|
|
712
|
+
as_html: bool = typer.Option(
|
|
713
|
+
False, "--html", help="Emit a standalone HTML page instead of markdown."
|
|
714
|
+
),
|
|
679
715
|
) -> None:
|
|
680
716
|
"""Build a one-page defence briefing from a past run."""
|
|
681
717
|
state = _context(ctx)
|
|
@@ -694,10 +730,17 @@ def brief(
|
|
|
694
730
|
return
|
|
695
731
|
|
|
696
732
|
entries = reporting.entries_from_run(run)
|
|
697
|
-
|
|
733
|
+
builder = briefing.build_brief_html if as_html else briefing.build_brief
|
|
734
|
+
markdown = builder(
|
|
698
735
|
entries,
|
|
699
736
|
run_id=str(run["run_id"]),
|
|
700
737
|
only_types=taxonomy.parse_filter(types),
|
|
738
|
+
only_channels={
|
|
739
|
+
chunk.strip().lower()
|
|
740
|
+
for value in channels or []
|
|
741
|
+
for chunk in value.split(",")
|
|
742
|
+
if chunk.strip()
|
|
743
|
+
},
|
|
701
744
|
)
|
|
702
745
|
_emit_report(state.console, markdown, output, force=force)
|
|
703
746
|
if output:
|
|
@@ -33,6 +33,7 @@ class ScriptBreakdown(BaseModel):
|
|
|
33
33
|
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
|
|
34
34
|
|
|
35
35
|
attack_type: str
|
|
36
|
+
channel: str = "other"
|
|
36
37
|
persona: str
|
|
37
38
|
opening_hook: str
|
|
38
39
|
trust_building_moves: list[str]
|
|
@@ -76,6 +77,22 @@ class ScriptBreakdown(BaseModel):
|
|
|
76
77
|
return out
|
|
77
78
|
return [str(value)]
|
|
78
79
|
|
|
80
|
+
@field_validator("channel", mode="before")
|
|
81
|
+
@classmethod
|
|
82
|
+
def _channel(cls, value: Any) -> str:
|
|
83
|
+
text = str(value or "").strip().lower()
|
|
84
|
+
aliases = {
|
|
85
|
+
"call": "vishing",
|
|
86
|
+
"phone": "vishing",
|
|
87
|
+
"voice": "vishing",
|
|
88
|
+
"text": "smishing",
|
|
89
|
+
"sms": "smishing",
|
|
90
|
+
"whatsapp": "smishing",
|
|
91
|
+
"mail": "email",
|
|
92
|
+
}
|
|
93
|
+
text = aliases.get(text, text)
|
|
94
|
+
return text if text in {"vishing", "smishing", "email", "other"} else "other"
|
|
95
|
+
|
|
79
96
|
@field_validator("sophistication_score", mode="before")
|
|
80
97
|
@classmethod
|
|
81
98
|
def _coerce_score(cls, value: Any) -> int:
|
|
@@ -22,6 +22,7 @@ markdown. It must validate against this schema:
|
|
|
22
22
|
|
|
23
23
|
{
|
|
24
24
|
"attack_type": "string: the fraud family, e.g. vishing, smishing, romance scam, tech support scam, government impersonation, refund scam, crypto investment scam",
|
|
25
|
+
"channel": "string: the delivery route, exactly one of vishing, smishing, email, other",
|
|
25
26
|
"persona": "string: the identity, authority, and tone the caller adopts, including any claimed employer, department, or credentials",
|
|
26
27
|
"opening_hook": "string: the literal first line or lure that grabs attention, quoted from the script when present",
|
|
27
28
|
"trust_building_moves": ["string: each credibility move in order, e.g. badge numbers, case IDs, hold music, spoofed caller ID, callback numbers, jargon"],
|
|
@@ -34,6 +35,7 @@ markdown. It must validate against this schema:
|
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
Rules:
|
|
38
|
+
- channel is about delivery, not theme: a delivery-themed text message is channel smishing, a phone call is vishing.
|
|
37
39
|
- opening_hook must quote the script verbatim when it has dialogue.
|
|
38
40
|
- Every list must hold at least three concrete items unless the artifact truly cannot support that, in which case give what exists.
|
|
39
41
|
- escalation_ladder must be ordered and must end with the walk-away path.
|
|
@@ -118,6 +118,7 @@ def render_breakdown(
|
|
|
118
118
|
style=SECTION_COLORS["attack_type"],
|
|
119
119
|
)
|
|
120
120
|
attack.append(" ")
|
|
121
|
+
attack.append(f"VIA {breakdown.channel.upper()} ", style="dim")
|
|
121
122
|
attack.append("SOPHISTICATION ", style="dim")
|
|
122
123
|
attack.append(
|
|
123
124
|
f"{breakdown.sophistication_score}/10",
|
|
@@ -33,6 +33,7 @@ class ReportEntry:
|
|
|
33
33
|
skipped: bool = False
|
|
34
34
|
fingerprint: str = ""
|
|
35
35
|
attack_slug: str = ""
|
|
36
|
+
channel: str = "other"
|
|
36
37
|
duplicates: list[str] | None = None
|
|
37
38
|
verification: dict[str, int] | None = None
|
|
38
39
|
unverified: list[dict[str, str]] | None = None
|
|
@@ -50,6 +51,7 @@ def entry_from_outcome(outcome: Any) -> ReportEntry:
|
|
|
50
51
|
skipped=getattr(outcome, "skipped", False),
|
|
51
52
|
fingerprint=getattr(outcome, "fingerprint", ""),
|
|
52
53
|
attack_slug=getattr(outcome, "attack_slug", ""),
|
|
54
|
+
channel=getattr(outcome, "channel", "other"),
|
|
53
55
|
error=outcome.error,
|
|
54
56
|
duration=getattr(outcome, "duration", 0.0),
|
|
55
57
|
verification=getattr(outcome, "verification", None),
|
|
@@ -79,6 +81,7 @@ def entries_from_run(run: dict[str, Any]) -> list[ReportEntry]:
|
|
|
79
81
|
skipped=bool(record.get("skipped")),
|
|
80
82
|
fingerprint=record.get("fingerprint") or "",
|
|
81
83
|
attack_slug=record.get("attack_slug") or "",
|
|
84
|
+
channel=record.get("channel") or "other",
|
|
82
85
|
duplicates=record.get("duplicates") or None,
|
|
83
86
|
error=None if breakdown else record.get("error") or "no breakdown stored",
|
|
84
87
|
duration=float(record.get("duration_s") or 0.0),
|
|
@@ -129,7 +132,10 @@ def build_markdown(
|
|
|
129
132
|
command: str = "batch",
|
|
130
133
|
model: str | None = None,
|
|
131
134
|
only_types: set[str] | None = None,
|
|
135
|
+
only_channels: set[str] | None = None,
|
|
132
136
|
) -> str:
|
|
137
|
+
if only_channels:
|
|
138
|
+
entries = [entry for entry in entries if not entry.ok or entry.channel in only_channels]
|
|
133
139
|
if only_types:
|
|
134
140
|
entries = [
|
|
135
141
|
entry
|
|
@@ -177,12 +183,13 @@ def build_markdown(
|
|
|
177
183
|
lines.append("")
|
|
178
184
|
lines.append("## Ranking by sophistication")
|
|
179
185
|
lines.append("")
|
|
180
|
-
lines.append("| Rank | Script | Type | Sophistication | Red flags | Unverified | Copies |")
|
|
181
|
-
lines.append("| ---: | --- | --- | ---: | ---: | ---: | ---: |")
|
|
186
|
+
lines.append("| Rank | Script | Type | Channel | Sophistication | Red flags | Unverified | Copies |")
|
|
187
|
+
lines.append("| ---: | --- | --- | --- | ---: | ---: | ---: | ---: |")
|
|
182
188
|
for position, entry in enumerate(ok, start=1):
|
|
183
189
|
lines.append(
|
|
184
190
|
f"| {position} | {_cell(entry.name)} | "
|
|
185
191
|
f"`{entry.attack_slug or taxonomy.slugify(entry.breakdown.attack_type)}` | "
|
|
192
|
+
f"`{entry.channel}` | "
|
|
186
193
|
f"{entry.breakdown.sophistication_score}/10 | {entry.breakdown.flag_count} | "
|
|
187
194
|
f"{len(entry.unverified or [])} | {1 + len(entry.duplicates or [])} |"
|
|
188
195
|
)
|
|
@@ -225,6 +232,7 @@ def _detail_section(position: int, entry: ReportEntry) -> list[str]:
|
|
|
225
232
|
lines.append(
|
|
226
233
|
f"- **Type slug:** `{entry.attack_slug or taxonomy.slugify(breakdown.attack_type)}`"
|
|
227
234
|
)
|
|
235
|
+
lines.append(f"- **Channel:** `{entry.channel}`")
|
|
228
236
|
lines.append(f"- **Sophistication:** {breakdown.sophistication_score}/10")
|
|
229
237
|
lines.append(f"- **Red flags observed:** {breakdown.flag_count}")
|
|
230
238
|
if entry.duplicates:
|
|
@@ -24,6 +24,13 @@ RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
|
24
24
|
("vishing", ("vishing", "phone call", "cold call", "robocall", "phone")),
|
|
25
25
|
)
|
|
26
26
|
|
|
27
|
+
CHANNELS = ("vishing", "smishing", "email", "other")
|
|
28
|
+
CHANNEL_RULES = (
|
|
29
|
+
("vishing", ("vishing", "call", "phone", "robocall", "cold call", "landline")),
|
|
30
|
+
("smishing", ("smishing", "sms", "text message", "text-message", "whatsapp")),
|
|
31
|
+
("email", ("email", "e-mail", "phishing email")),
|
|
32
|
+
)
|
|
33
|
+
|
|
27
34
|
SLUGS = tuple(slug for slug, _ in RULES) + (OTHER,)
|
|
28
35
|
NON_WORD = re.compile(r"[^a-z0-9]+")
|
|
29
36
|
MATCHERS: tuple[tuple[str, re.Pattern[str]], ...] = tuple(
|
|
@@ -41,6 +48,15 @@ def slugify(attack_type: str) -> str:
|
|
|
41
48
|
return OTHER
|
|
42
49
|
|
|
43
50
|
|
|
51
|
+
def channel_of(*texts: str) -> str:
|
|
52
|
+
"""Infer the delivery channel when the model did not supply one."""
|
|
53
|
+
text = NON_WORD.sub(" ", " ".join(texts).lower())
|
|
54
|
+
for channel, needles in CHANNEL_RULES:
|
|
55
|
+
if any(re.search(r"\b" + re.escape(n) + r"\b", text) for n in needles):
|
|
56
|
+
return channel
|
|
57
|
+
return "other"
|
|
58
|
+
|
|
59
|
+
|
|
44
60
|
def unknown(values: list[str] | None) -> set[str]:
|
|
45
61
|
"""Raw filter tokens that are not a known slug."""
|
|
46
62
|
if not values:
|
|
@@ -55,3 +55,22 @@ def test_brief_caps_list_length():
|
|
|
55
55
|
flags = [f"flag {i}" for i in range(10)]
|
|
56
56
|
out = build_brief([entry("a", "vishing", flags, "c")], run_id="r")
|
|
57
57
|
assert out.count("- flag ") == 6
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_html_brief_structure_and_escaping():
|
|
61
|
+
from scamscript.brief import build_brief_html
|
|
62
|
+
|
|
63
|
+
out = build_brief_html(
|
|
64
|
+
[entry("a<b>.txt", "vishing", ["<script>alert(1)</script>"], "hang up & verify")], run_id="r"
|
|
65
|
+
)
|
|
66
|
+
assert out.startswith("<!DOCTYPE html>")
|
|
67
|
+
assert "<script>alert(1)</script>" not in out
|
|
68
|
+
assert "<script>alert(1)</script>" in out
|
|
69
|
+
assert "hang up & verify" in out
|
|
70
|
+
assert "<title>Scam defence briefing</title>" in out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_html_brief_empty():
|
|
74
|
+
from scamscript.brief import build_brief_html
|
|
75
|
+
|
|
76
|
+
assert "No matching successful analyses" in build_brief_html([], run_id="r")
|
|
@@ -146,15 +146,13 @@ def test_batch_top_limits_report(home, tmp_path, fake):
|
|
|
146
146
|
assert "## 2." not in markdown
|
|
147
147
|
|
|
148
148
|
|
|
149
|
-
def test_failure_exits_nonzero(home, tmp_path, fake):
|
|
149
|
+
def test_failure_exits_nonzero(home, tmp_path, fake, monkeypatch):
|
|
150
150
|
(tmp_path / "a.txt").write_text("CALLER: x", encoding="utf-8")
|
|
151
151
|
fake.default = None
|
|
152
|
-
|
|
153
|
-
fake.complete = lambda self, messages, **kwargs: "not json"
|
|
152
|
+
monkeypatch.setattr(FakeClient, "complete", lambda self, messages, **kwargs: "not json")
|
|
154
153
|
result = runner.invoke(cli.app, ["--api-key", "k", "analyze", "--file", str(tmp_path / "a.txt")])
|
|
155
154
|
assert result.exit_code == 1
|
|
156
155
|
assert "FAILED" in result.stdout
|
|
157
|
-
fake.complete = original
|
|
158
156
|
|
|
159
157
|
|
|
160
158
|
def test_report_replays_logged_run(home, tmp_path, fake):
|
|
@@ -230,7 +228,7 @@ def test_dry_run_without_endpoint(home, tmp_path, monkeypatch):
|
|
|
230
228
|
def test_version(home):
|
|
231
229
|
result = runner.invoke(cli.app, ["--version"])
|
|
232
230
|
assert result.exit_code == 0
|
|
233
|
-
assert result.stdout.strip() == "scamscript 0.
|
|
231
|
+
assert result.stdout.strip() == "scamscript 0.8.0"
|
|
234
232
|
|
|
235
233
|
|
|
236
234
|
def test_json_output_is_one_object_per_script(home, tmp_path, fake):
|
|
@@ -480,14 +478,14 @@ def test_unknown_type_warns(home, tmp_path, fake):
|
|
|
480
478
|
assert "unknown type" in result.stdout
|
|
481
479
|
|
|
482
480
|
|
|
483
|
-
def test_language_override_reaches_prompt(home, tmp_path, fake):
|
|
481
|
+
def test_language_override_reaches_prompt(home, tmp_path, fake, monkeypatch):
|
|
484
482
|
seen = {}
|
|
485
483
|
|
|
486
484
|
def complete(self, messages, **kwargs):
|
|
487
485
|
seen["prompt"] = messages[-1]["content"]
|
|
488
486
|
return json.dumps(breakdown())
|
|
489
487
|
|
|
490
|
-
|
|
488
|
+
monkeypatch.setattr(FakeClient, "complete", complete)
|
|
491
489
|
script = tmp_path / "a.txt"
|
|
492
490
|
script.write_text("CALLER: hola", encoding="utf-8")
|
|
493
491
|
result = runner.invoke(
|
|
@@ -495,4 +493,50 @@ def test_language_override_reaches_prompt(home, tmp_path, fake):
|
|
|
495
493
|
)
|
|
496
494
|
assert result.exit_code == 0
|
|
497
495
|
assert "'es'" in seen["prompt"]
|
|
498
|
-
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def test_channel_filter_and_unknown_warning(home, tmp_path, fake):
|
|
499
|
+
(tmp_path / "a.txt").write_text("CALLER: call me", encoding="utf-8")
|
|
500
|
+
(tmp_path / "b.txt").write_text("CALLER: text me", encoding="utf-8")
|
|
501
|
+
vish = breakdown()
|
|
502
|
+
vish["channel"] = "vishing"
|
|
503
|
+
smish = breakdown()
|
|
504
|
+
smish["channel"] = "smishing"
|
|
505
|
+
fake.replies = {"a.txt": vish, "b.txt": smish}
|
|
506
|
+
out = tmp_path / "r.md"
|
|
507
|
+
result = runner.invoke(
|
|
508
|
+
cli.app, ["--api-key", "k", "batch", str(tmp_path), "--channel", "vishing", "--output", str(out)]
|
|
509
|
+
)
|
|
510
|
+
assert result.exit_code == 0
|
|
511
|
+
assert "a.txt" in out.read_text() and "b.txt" not in out.read_text()
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def test_unknown_channel_warns(home, tmp_path, fake):
|
|
515
|
+
(tmp_path / "a.txt").write_text("CALLER: x", encoding="utf-8")
|
|
516
|
+
result = runner.invoke(cli.app, ["--api-key", "k", "batch", str(tmp_path), "--channel", "carrier-pigeon"])
|
|
517
|
+
assert result.exit_code == 0
|
|
518
|
+
assert "unknown channel" in result.stdout
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def test_brief_html_output(home, tmp_path, fake):
|
|
522
|
+
script = tmp_path / "a.txt"
|
|
523
|
+
script.write_text("CALLER: x", encoding="utf-8")
|
|
524
|
+
runner.invoke(cli.app, ["--api-key", "k", "analyze", "--file", str(script)])
|
|
525
|
+
out = tmp_path / "b.html"
|
|
526
|
+
result = runner.invoke(cli.app, ["brief", "--html", "--output", str(out)])
|
|
527
|
+
assert result.exit_code == 0
|
|
528
|
+
assert out.read_text().startswith("<!DOCTYPE html>")
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def test_brief_refuses_overwrite_without_force(home, tmp_path, fake):
|
|
532
|
+
script = tmp_path / "a.txt"
|
|
533
|
+
script.write_text("CALLER: x", encoding="utf-8")
|
|
534
|
+
runner.invoke(cli.app, ["--api-key", "k", "analyze", "--file", str(script)])
|
|
535
|
+
out = tmp_path / "b.md"
|
|
536
|
+
out.write_text("old", encoding="utf-8")
|
|
537
|
+
refused = runner.invoke(cli.app, ["brief", "--output", str(out)])
|
|
538
|
+
assert refused.exit_code == 1
|
|
539
|
+
assert "pass --force" in refused.stdout
|
|
540
|
+
forced = runner.invoke(cli.app, ["brief", "--output", str(out), "--force"])
|
|
541
|
+
assert forced.exit_code == 0
|
|
542
|
+
assert "Scam defence briefing" in out.read_text()
|
|
@@ -31,7 +31,7 @@ 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 | 0 | 1 |" in markdown
|
|
34
|
+
assert "| 1 | a.txt | `vishing` | `other` | 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
|
|
@@ -90,3 +90,12 @@ def test_type_filter_keeps_only_allowed_and_failures():
|
|
|
90
90
|
assert "a.txt" in markdown
|
|
91
91
|
assert "b.txt" not in markdown
|
|
92
92
|
assert "bad.txt" in markdown
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_channel_filter():
|
|
96
|
+
entries = [
|
|
97
|
+
ReportEntry(name="a.txt", source="a", breakdown=breakdown(5), channel="vishing"),
|
|
98
|
+
ReportEntry(name="b.txt", source="b", breakdown=breakdown(5), channel="smishing"),
|
|
99
|
+
]
|
|
100
|
+
markdown = build_markdown(entries, run_id="r", only_channels={"vishing"})
|
|
101
|
+
assert "a.txt" in markdown and "b.txt" not in markdown
|
|
@@ -54,3 +54,25 @@ def test_parse_filter_accepts_comma_and_spaces():
|
|
|
54
54
|
def test_label_known_and_fallback():
|
|
55
55
|
assert label("bec") == "Business email compromise"
|
|
56
56
|
assert label("odd") == "odd"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.mark.parametrize(
|
|
60
|
+
"texts,channel",
|
|
61
|
+
[
|
|
62
|
+
(("USPS delivery smishing text",), "smishing"),
|
|
63
|
+
(("phone robocall",), "vishing"),
|
|
64
|
+
(("vendor invoice", "email"), "email"),
|
|
65
|
+
(("something",), "other"),
|
|
66
|
+
(("first", "second"), "other"),
|
|
67
|
+
],
|
|
68
|
+
)
|
|
69
|
+
def test_channel_of(texts, channel):
|
|
70
|
+
from scamscript.taxonomy import channel_of
|
|
71
|
+
|
|
72
|
+
assert channel_of(*texts) == channel
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_channel_matching_uses_word_boundaries():
|
|
76
|
+
from scamscript.taxonomy import channel_of
|
|
77
|
+
|
|
78
|
+
assert channel_of("rainfall") == "other"
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
"""Turn a recorded run into a one-page defence briefing for trainers."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from scamscript import taxonomy
|
|
6
|
-
from scamscript.report import ReportEntry
|
|
7
|
-
|
|
8
|
-
MAX_FLAGS = 6
|
|
9
|
-
MAX_PRESSURE = 4
|
|
10
|
-
MAX_COUNTERS = 4
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def _unique(values: list[str], limit: int) -> list[str]:
|
|
14
|
-
seen: set[str] = set()
|
|
15
|
-
out: list[str] = []
|
|
16
|
-
for value in values:
|
|
17
|
-
key = value.strip().lower()
|
|
18
|
-
if not key or key in seen:
|
|
19
|
-
continue
|
|
20
|
-
seen.add(key)
|
|
21
|
-
out.append(value.strip())
|
|
22
|
-
if len(out) >= limit:
|
|
23
|
-
break
|
|
24
|
-
return out
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def build_brief(
|
|
28
|
-
entries: list[ReportEntry], run_id: str, only_types: set[str] | None = None
|
|
29
|
-
) -> str:
|
|
30
|
-
groups: dict[str, list[ReportEntry]] = {}
|
|
31
|
-
for entry in entries:
|
|
32
|
-
if not entry.ok or not entry.breakdown:
|
|
33
|
-
continue
|
|
34
|
-
slug = entry.attack_slug or taxonomy.slugify(entry.breakdown.attack_type)
|
|
35
|
-
if only_types and slug not in only_types:
|
|
36
|
-
continue
|
|
37
|
-
groups.setdefault(slug, []).append(entry)
|
|
38
|
-
|
|
39
|
-
lines = ["# Scam defence briefing", ""]
|
|
40
|
-
lines.append(f"Built from run `{run_id}`. Every claim comes from the analyzed scripts.")
|
|
41
|
-
lines.append("")
|
|
42
|
-
if not groups:
|
|
43
|
-
lines.append("_No matching successful analyses in this run._")
|
|
44
|
-
return "\n".join(lines) + "\n"
|
|
45
|
-
|
|
46
|
-
lines.append("Use this in order: the flags are what a target can notice, the counters are what")
|
|
47
|
-
lines.append("they should do instead of complying.")
|
|
48
|
-
lines.append("")
|
|
49
|
-
|
|
50
|
-
ordered = sorted(groups.items(), key=lambda item: (-len(item[1]), item[0]))
|
|
51
|
-
for slug, group in ordered:
|
|
52
|
-
scores = [entry.breakdown.sophistication_score for entry in group]
|
|
53
|
-
flags = _unique([flag for entry in group for flag in entry.breakdown.red_flags], MAX_FLAGS)
|
|
54
|
-
pressure = _unique(
|
|
55
|
-
[
|
|
56
|
-
item
|
|
57
|
-
for entry in group
|
|
58
|
-
for item in entry.breakdown.urgency_tactics + entry.breakdown.escalation_ladder
|
|
59
|
-
],
|
|
60
|
-
MAX_PRESSURE,
|
|
61
|
-
)
|
|
62
|
-
counters = _unique(
|
|
63
|
-
[entry.breakdown.defense_summary for entry in group], MAX_COUNTERS
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
lines.append(f"## {taxonomy.label(slug)} (`{slug}`)")
|
|
67
|
-
lines.append("")
|
|
68
|
-
lines.append(f"{len(group)} script(s), mean sophistication {sum(scores) / len(scores):.1f}/10.")
|
|
69
|
-
lines.append("")
|
|
70
|
-
lines.append("### What to notice")
|
|
71
|
-
lines.append("")
|
|
72
|
-
lines.extend(f"- {flag}" for flag in flags or ["_Nothing recorded._"])
|
|
73
|
-
lines.append("")
|
|
74
|
-
if pressure:
|
|
75
|
-
lines.append("### How pressure is applied")
|
|
76
|
-
lines.append("")
|
|
77
|
-
lines.extend(f"- {item}" for item in pressure)
|
|
78
|
-
lines.append("")
|
|
79
|
-
lines.append("### What to do")
|
|
80
|
-
lines.append("")
|
|
81
|
-
lines.extend(f"- {counter}" for counter in counters or ["_Nothing recorded._"])
|
|
82
|
-
lines.append("")
|
|
83
|
-
lines.append("### Sources")
|
|
84
|
-
lines.append("")
|
|
85
|
-
lines.extend(f"- `{entry.name}`" for entry in group)
|
|
86
|
-
lines.append("")
|
|
87
|
-
|
|
88
|
-
lines.append("---")
|
|
89
|
-
lines.append("")
|
|
90
|
-
lines.append(
|
|
91
|
-
"_Model-produced reading of captured scripts, for security research and fraud-awareness "
|
|
92
|
-
"training only._"
|
|
93
|
-
)
|
|
94
|
-
lines.append("")
|
|
95
|
-
return "\n".join(lines)
|
|
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
|
|
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
|