scamscript 0.5.0__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.
Files changed (69) hide show
  1. {scamscript-0.5.0 → scamscript-0.6.0}/CHANGELOG.md +15 -0
  2. {scamscript-0.5.0 → scamscript-0.6.0}/PKG-INFO +18 -3
  3. {scamscript-0.5.0 → scamscript-0.6.0}/README.md +17 -2
  4. {scamscript-0.5.0 → scamscript-0.6.0}/pyproject.toml +1 -1
  5. {scamscript-0.5.0 → scamscript-0.6.0}/src/scamscript/__init__.py +1 -1
  6. {scamscript-0.5.0 → scamscript-0.6.0}/src/scamscript/analysis.py +4 -1
  7. {scamscript-0.5.0 → scamscript-0.6.0}/src/scamscript/cli.py +30 -3
  8. {scamscript-0.5.0 → scamscript-0.6.0}/src/scamscript/client.py +1 -1
  9. {scamscript-0.5.0 → scamscript-0.6.0}/src/scamscript/render.py +2 -0
  10. {scamscript-0.5.0 → scamscript-0.6.0}/src/scamscript/report.py +38 -2
  11. {scamscript-0.5.0 → scamscript-0.6.0}/src/scamscript/store.py +19 -1
  12. scamscript-0.6.0/src/scamscript/taxonomy.py +77 -0
  13. {scamscript-0.5.0 → scamscript-0.6.0}/tests/test_cli.py +24 -1
  14. {scamscript-0.5.0 → scamscript-0.6.0}/tests/test_report.py +14 -1
  15. {scamscript-0.5.0 → scamscript-0.6.0}/tests/test_store.py +10 -0
  16. scamscript-0.6.0/tests/test_taxonomy.py +51 -0
  17. {scamscript-0.5.0 → scamscript-0.6.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  18. {scamscript-0.5.0 → scamscript-0.6.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  19. {scamscript-0.5.0 → scamscript-0.6.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  20. {scamscript-0.5.0 → scamscript-0.6.0}/.github/SECURITY.md +0 -0
  21. {scamscript-0.5.0 → scamscript-0.6.0}/.github/pull_request_template.md +0 -0
  22. {scamscript-0.5.0 → scamscript-0.6.0}/.github/workflows/ci.yml +0 -0
  23. {scamscript-0.5.0 → scamscript-0.6.0}/.github/workflows/release.yml +0 -0
  24. {scamscript-0.5.0 → scamscript-0.6.0}/.gitignore +0 -0
  25. {scamscript-0.5.0 → scamscript-0.6.0}/CITATION.cff +0 -0
  26. {scamscript-0.5.0 → scamscript-0.6.0}/CODE_OF_CONDUCT.md +0 -0
  27. {scamscript-0.5.0 → scamscript-0.6.0}/CONTRIBUTING.md +0 -0
  28. {scamscript-0.5.0 → scamscript-0.6.0}/LICENSE +0 -0
  29. {scamscript-0.5.0 → scamscript-0.6.0}/docs/banner.svg +0 -0
  30. {scamscript-0.5.0 → scamscript-0.6.0}/docs/calibration.md +0 -0
  31. {scamscript-0.5.0 → scamscript-0.6.0}/docs/demo.gif +0 -0
  32. {scamscript-0.5.0 → scamscript-0.6.0}/docs/sample-report.md +0 -0
  33. {scamscript-0.5.0 → scamscript-0.6.0}/docs/social-preview.png +0 -0
  34. {scamscript-0.5.0 → scamscript-0.6.0}/examples/adversarial/fake_output_json.txt +0 -0
  35. {scamscript-0.5.0 → scamscript-0.6.0}/examples/adversarial/fence_escape.txt +0 -0
  36. {scamscript-0.5.0 → scamscript-0.6.0}/examples/adversarial/fixed_attack_type.txt +0 -0
  37. {scamscript-0.5.0 → scamscript-0.6.0}/examples/adversarial/non_json_demand.txt +0 -0
  38. {scamscript-0.5.0 → scamscript-0.6.0}/examples/adversarial/print_system_prompt.txt +0 -0
  39. {scamscript-0.5.0 → scamscript-0.6.0}/examples/adversarial/role_switch.txt +0 -0
  40. {scamscript-0.5.0 → scamscript-0.6.0}/examples/adversarial/score_override.txt +0 -0
  41. {scamscript-0.5.0 → scamscript-0.6.0}/examples/crude_robocall.txt +0 -0
  42. {scamscript-0.5.0 → scamscript-0.6.0}/examples/gov_impersonation_vishing.txt +0 -0
  43. {scamscript-0.5.0 → scamscript-0.6.0}/examples/prompt_injection_script.txt +0 -0
  44. {scamscript-0.5.0 → scamscript-0.6.0}/examples/romance_scam_sms.txt +0 -0
  45. {scamscript-0.5.0 → scamscript-0.6.0}/examples/sophisticated_wire_fraud.txt +0 -0
  46. {scamscript-0.5.0 → scamscript-0.6.0}/examples/tech_support_scam.txt +0 -0
  47. {scamscript-0.5.0 → scamscript-0.6.0}/scripts/calibrate.py +0 -0
  48. {scamscript-0.5.0 → scamscript-0.6.0}/scripts/injection_eval.py +0 -0
  49. {scamscript-0.5.0 → scamscript-0.6.0}/src/scamscript/__main__.py +0 -0
  50. {scamscript-0.5.0 → scamscript-0.6.0}/src/scamscript/config.py +0 -0
  51. {scamscript-0.5.0 → scamscript-0.6.0}/src/scamscript/dedupe.py +0 -0
  52. {scamscript-0.5.0 → scamscript-0.6.0}/src/scamscript/ingest.py +0 -0
  53. {scamscript-0.5.0 → scamscript-0.6.0}/src/scamscript/models.py +0 -0
  54. {scamscript-0.5.0 → scamscript-0.6.0}/src/scamscript/prompts.py +0 -0
  55. {scamscript-0.5.0 → scamscript-0.6.0}/src/scamscript/redact.py +0 -0
  56. {scamscript-0.5.0 → scamscript-0.6.0}/src/scamscript/verify.py +0 -0
  57. {scamscript-0.5.0 → scamscript-0.6.0}/tests/fixtures/reprints/copy-header.txt +0 -0
  58. {scamscript-0.5.0 → scamscript-0.6.0}/tests/fixtures/reprints/copy-punctuation.txt +0 -0
  59. {scamscript-0.5.0 → scamscript-0.6.0}/tests/fixtures/reprints/near-twin.txt +0 -0
  60. {scamscript-0.5.0 → scamscript-0.6.0}/tests/fixtures/reprints/original.txt +0 -0
  61. {scamscript-0.5.0 → scamscript-0.6.0}/tests/test_analysis.py +0 -0
  62. {scamscript-0.5.0 → scamscript-0.6.0}/tests/test_client.py +0 -0
  63. {scamscript-0.5.0 → scamscript-0.6.0}/tests/test_dedupe.py +0 -0
  64. {scamscript-0.5.0 → scamscript-0.6.0}/tests/test_ingest.py +0 -0
  65. {scamscript-0.5.0 → scamscript-0.6.0}/tests/test_models.py +0 -0
  66. {scamscript-0.5.0 → scamscript-0.6.0}/tests/test_prompts.py +0 -0
  67. {scamscript-0.5.0 → scamscript-0.6.0}/tests/test_redact.py +0 -0
  68. {scamscript-0.5.0 → scamscript-0.6.0}/tests/test_render.py +0 -0
  69. {scamscript-0.5.0 → scamscript-0.6.0}/tests/test_verify.py +0 -0
@@ -5,6 +5,21 @@ 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
+
8
23
  ## 0.5.0 - 2026-11-02
9
24
 
10
25
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: scamscript
3
- Version: 0.5.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
@@ -134,7 +134,7 @@ corpus is at [`docs/sample-report.md`](https://github.com/sjdsjadjajdjsa/scamscr
134
134
 
135
135
  Useful flags: `--dry-run`, `--json`, `--redact`, `--top N`, `--recursive`, `--timeout`,
136
136
  `--retries`, `--max-tokens`, `--concurrency`, `--max-cost`, `--dedupe/--no-dedupe`,
137
- `--show-duplicates`, `--no-color`. `--json` puts one object per script on stdout and all
137
+ `--show-duplicates`, `--type`, `--no-color`. `--json` puts one object per script on stdout and all
138
138
  Rich output on stderr, so it pipes:
139
139
 
140
140
  ```bash
@@ -155,6 +155,20 @@ The cap reads `usage.cost` from the response, so it works only where the provide
155
155
  says so when none is sent. Spend is printed and logged per run. In-flight requests can overshoot by
156
156
  up to the concurrency, so keep headroom on a hard limit.
157
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
+
158
172
  ## Duplicates
159
173
 
160
174
  Corpora are full of reprints. Each script is fingerprinted on its body: headers, casing, punctuation
@@ -204,7 +218,7 @@ masking is best effort: names, case IDs and spelled-out digits survive it.
204
218
  Every request appends to a timestamped JSONL file under `~/.scamscript/logs/` (override with
205
219
  `--home` or `SCAMSCRIPT_HOME`). The API key is never written. Logs are the source for `report`, so
206
220
  a run replays without spending tokens, and `prune --keep 200 --keep-days 30` keeps the store
207
- bounded.
221
+ bounded. Directories are `0700` and log files `0600`.
208
222
 
209
223
  ## Install and configure
210
224
 
@@ -277,6 +291,7 @@ docs/ calibration record, sample report, banner, demo
277
291
  `opening_hook` and `red_flags` against the source before quoting anything.
278
292
  - Redaction is regex-based and will miss names and identifiers without a fixed shape.
279
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.
280
295
  - The cost cap depends on provider-reported usage and can overshoot by the in-flight count.
281
296
  - Analysis is English-centric: the prompt and schema assume English artifacts.
282
297
  - Quote checking compares text, not meaning. A legitimate paraphrase left in quotes is flagged;
@@ -98,7 +98,7 @@ corpus is at [`docs/sample-report.md`](https://github.com/sjdsjadjajdjsa/scamscr
98
98
 
99
99
  Useful flags: `--dry-run`, `--json`, `--redact`, `--top N`, `--recursive`, `--timeout`,
100
100
  `--retries`, `--max-tokens`, `--concurrency`, `--max-cost`, `--dedupe/--no-dedupe`,
101
- `--show-duplicates`, `--no-color`. `--json` puts one object per script on stdout and all
101
+ `--show-duplicates`, `--type`, `--no-color`. `--json` puts one object per script on stdout and all
102
102
  Rich output on stderr, so it pipes:
103
103
 
104
104
  ```bash
@@ -119,6 +119,20 @@ The cap reads `usage.cost` from the response, so it works only where the provide
119
119
  says so when none is sent. Spend is printed and logged per run. In-flight requests can overshoot by
120
120
  up to the concurrency, so keep headroom on a hard limit.
121
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
+
122
136
  ## Duplicates
123
137
 
124
138
  Corpora are full of reprints. Each script is fingerprinted on its body: headers, casing, punctuation
@@ -168,7 +182,7 @@ masking is best effort: names, case IDs and spelled-out digits survive it.
168
182
  Every request appends to a timestamped JSONL file under `~/.scamscript/logs/` (override with
169
183
  `--home` or `SCAMSCRIPT_HOME`). The API key is never written. Logs are the source for `report`, so
170
184
  a run replays without spending tokens, and `prune --keep 200 --keep-days 30` keeps the store
171
- bounded.
185
+ bounded. Directories are `0700` and log files `0600`.
172
186
 
173
187
  ## Install and configure
174
188
 
@@ -241,6 +255,7 @@ docs/ calibration record, sample report, banner, demo
241
255
  `opening_hook` and `red_flags` against the source before quoting anything.
242
256
  - Redaction is regex-based and will miss names and identifiers without a fixed shape.
243
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.
244
259
  - The cost cap depends on provider-reported usage and can overshoot by the in-flight count.
245
260
  - Analysis is English-centric: the prompt and schema assume English artifacts.
246
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.5.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"
@@ -1,3 +1,3 @@
1
1
  """scamscript: reverse-engineering analyzer for scam call scripts."""
2
2
 
3
- __version__ = "0.5.0"
3
+ __version__ = "0.6.0"
@@ -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 dedupe, 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
@@ -28,6 +28,7 @@ class Outcome:
28
28
  redactions: dict[str, int] | None = None
29
29
  skipped: bool = False
30
30
  fingerprint: str = ""
31
+ attack_slug: str = ""
31
32
  duplicate_of: str | None = None
32
33
  issues: list[verify.Issue] | None = None
33
34
  verification: dict[str, int] | None = None
@@ -44,6 +45,7 @@ class Outcome:
44
45
  "ok": self.ok,
45
46
  "skipped": self.skipped,
46
47
  "fingerprint": self.fingerprint,
48
+ "attack_slug": self.attack_slug,
47
49
  "duplicate_of": self.duplicate_of,
48
50
  "attempts": self.attempts,
49
51
  "duration_s": round(self.duration, 3),
@@ -119,6 +121,7 @@ def analyze_script(
119
121
  try:
120
122
  outcome.breakdown = parse_breakdown(visible)
121
123
  outcome.error = None
124
+ outcome.attack_slug = taxonomy.slugify(outcome.breakdown.attack_type)
122
125
  if verify_quotes:
123
126
  outcome.issues = verify.verify_breakdown(outcome.breakdown, text)
124
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
@@ -234,6 +234,7 @@ def _run(
234
234
  batch_mode: bool,
235
235
  output: Path | None,
236
236
  top: int | None,
237
+ types: list[str] | None = None,
237
238
  ) -> None:
238
239
  console = state.console
239
240
  settings = state.settings
@@ -412,6 +413,13 @@ def _run(
412
413
  cost_usd=round(spent, 6),
413
414
  )
414
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
+ )
415
423
  collapsed = reporting.collapse(report_entries) if state.dedupe else report_entries
416
424
 
417
425
  if batch_mode and (successes or skipped):
@@ -430,6 +438,7 @@ def _run(
430
438
  run_id=logger.run_id,
431
439
  command=command,
432
440
  model=settings.model,
441
+ only_types=only_types,
433
442
  )
434
443
  written = _emit_report(console, markdown, output, json_mode=state.json_output)
435
444
  if state.json_output:
@@ -496,6 +505,9 @@ def analyze(
496
505
  ),
497
506
  recursive: bool = typer.Option(False, "--recursive", "-r", help="Recurse into subdirectories."),
498
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
+ ),
499
511
  ) -> None:
500
512
  """Analyze a single script file, or a directory with --batch."""
501
513
  state = _context(ctx)
@@ -508,7 +520,15 @@ def analyze(
508
520
  except IngestError as exc:
509
521
  _fail(state.console, str(exc))
510
522
  return
511
- _run(state, scripts, command="analyze", batch_mode=batch_mode, output=output, top=top)
523
+ _run(
524
+ state,
525
+ scripts,
526
+ command="analyze",
527
+ batch_mode=batch_mode,
528
+ output=output,
529
+ top=top,
530
+ types=types,
531
+ )
512
532
 
513
533
 
514
534
  @app.command()
@@ -520,6 +540,9 @@ def batch(
520
540
  ),
521
541
  recursive: bool = typer.Option(False, "--recursive", "-r", help="Recurse into subdirectories."),
522
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
+ ),
523
546
  ) -> None:
524
547
  """Analyze a directory and rank every script by sophistication."""
525
548
  state = _context(ctx)
@@ -528,7 +551,7 @@ def batch(
528
551
  except IngestError as exc:
529
552
  _fail(state.console, str(exc))
530
553
  return
531
- _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)
532
555
 
533
556
 
534
557
  @app.command()
@@ -539,6 +562,9 @@ def report( # noqa: A001
539
562
  ),
540
563
  output: Path | None = typer.Option(None, "--output", "-o", help="Write the markdown here."),
541
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
+ ),
542
568
  ) -> None:
543
569
  """Regenerate the aggregated report from a past run's log."""
544
570
  state = _context(ctx)
@@ -565,6 +591,7 @@ def report( # noqa: A001
565
591
  run_id=str(run["run_id"]),
566
592
  command=str(run.get("command") or "report"),
567
593
  model=run.get("model"),
594
+ only_types=taxonomy.parse_filter(types),
568
595
  )
569
596
  _emit_report(state.console, markdown, output)
570
597
 
@@ -63,7 +63,7 @@ class ChatClient:
63
63
  "Authorization": f"Bearer {api_key}",
64
64
  "Content-Type": "application/json",
65
65
  "Accept": "application/json",
66
- "User-Agent": "scamscript/0.5.0",
66
+ "User-Agent": "scamscript/0.6.0",
67
67
  },
68
68
  )
69
69
 
@@ -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
 
@@ -31,6 +32,7 @@ class ReportEntry:
31
32
  duration: float = 0.0
32
33
  skipped: bool = False
33
34
  fingerprint: str = ""
35
+ attack_slug: str = ""
34
36
  duplicates: list[str] | None = None
35
37
  verification: dict[str, int] | None = None
36
38
  unverified: list[dict[str, str]] | None = None
@@ -47,6 +49,7 @@ def entry_from_outcome(outcome: Any) -> ReportEntry:
47
49
  breakdown=outcome.breakdown,
48
50
  skipped=getattr(outcome, "skipped", False),
49
51
  fingerprint=getattr(outcome, "fingerprint", ""),
52
+ attack_slug=getattr(outcome, "attack_slug", ""),
50
53
  error=outcome.error,
51
54
  duration=getattr(outcome, "duration", 0.0),
52
55
  verification=getattr(outcome, "verification", None),
@@ -75,6 +78,7 @@ def entries_from_run(run: dict[str, Any]) -> list[ReportEntry]:
75
78
  breakdown=breakdown,
76
79
  skipped=bool(record.get("skipped")),
77
80
  fingerprint=record.get("fingerprint") or "",
81
+ attack_slug=record.get("attack_slug") or "",
78
82
  duplicates=record.get("duplicates") or None,
79
83
  error=None if breakdown else record.get("error") or "no breakdown stored",
80
84
  duration=float(record.get("duration_s") or 0.0),
@@ -124,7 +128,14 @@ def build_markdown(
124
128
  run_id: str,
125
129
  command: str = "batch",
126
130
  model: str | None = None,
131
+ only_types: set[str] | None = None,
127
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
+ ]
128
139
  ranked = rank(entries)
129
140
  ok = [entry for entry in ranked if entry.ok]
130
141
  skipped = [entry for entry in ranked if entry.skipped and not entry.ok]
@@ -157,13 +168,21 @@ def build_markdown(
157
168
  lines.append("")
158
169
 
159
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("")
160
178
  lines.append("## Ranking by sophistication")
161
179
  lines.append("")
162
- lines.append("| Rank | Script | Attack type | Sophistication | Red flags | Unverified | Copies |")
180
+ lines.append("| Rank | Script | Type | Sophistication | Red flags | Unverified | Copies |")
163
181
  lines.append("| ---: | --- | --- | ---: | ---: | ---: | ---: |")
164
182
  for position, entry in enumerate(ok, start=1):
165
183
  lines.append(
166
- f"| {position} | {_cell(entry.name)} | {_cell(entry.breakdown.attack_type)} | "
184
+ f"| {position} | {_cell(entry.name)} | "
185
+ f"`{entry.attack_slug or taxonomy.slugify(entry.breakdown.attack_type)}` | "
167
186
  f"{entry.breakdown.sophistication_score}/10 | {entry.breakdown.flag_count} | "
168
187
  f"{len(entry.unverified or [])} | {1 + len(entry.duplicates or [])} |"
169
188
  )
@@ -203,6 +222,9 @@ def _detail_section(position: int, entry: ReportEntry) -> list[str]:
203
222
  assert breakdown is not None
204
223
  lines = [f"## {position}. {entry.name}", ""]
205
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
+ )
206
228
  lines.append(f"- **Sophistication:** {breakdown.sophistication_score}/10")
207
229
  lines.append(f"- **Red flags observed:** {breakdown.flag_count}")
208
230
  if entry.duplicates:
@@ -238,5 +260,19 @@ def _detail_section(position: int, entry: ReportEntry) -> list[str]:
238
260
  return lines
239
261
 
240
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
+
241
277
  def _cell(value: str) -> str:
242
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.logs_dir.mkdir(parents=True, exist_ok=True)
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
@@ -230,7 +230,7 @@ def test_dry_run_without_endpoint(home, tmp_path, monkeypatch):
230
230
  def test_version(home):
231
231
  result = runner.invoke(cli.app, ["--version"])
232
232
  assert result.exit_code == 0
233
- assert result.stdout.strip() == "scamscript 0.5.0"
233
+ assert result.stdout.strip() == "scamscript 0.6.0"
234
234
 
235
235
 
236
236
  def test_json_output_is_one_object_per_script(home, tmp_path, fake):
@@ -455,3 +455,26 @@ def test_json_marks_duplicates(home, tmp_path, fake):
455
455
  results = [record for record in records if record.get("event") == "result"]
456
456
  assert all(record["fingerprint"] for record in results)
457
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
@@ -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