{"id":50411986,"url":"https://github.com/srkyn/lapse","last_synced_at":"2026-05-31T04:02:56.487Z","repository":{"id":355961848,"uuid":"1230004282","full_name":"srkyn/lapse","owner":"srkyn","description":"Identify and clean up stale Entra ID device objects using dual-signal detection.","archived":false,"fork":false,"pushed_at":"2026-05-06T02:23:20.000Z","size":23,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-06T03:30:28.908Z","etag":null,"topics":["audit","azure-ad","cli","device-management","entra-id","identity","microsoft-graph","python","security"],"latest_commit_sha":null,"homepage":"https://github.com/srkyn/lapse","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/srkyn.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-05T15:29:04.000Z","updated_at":"2026-05-06T02:23:23.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/srkyn/lapse","commit_stats":null,"previous_names":["srkyn/lapse"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/srkyn/lapse","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/srkyn%2Flapse","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/srkyn%2Flapse/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/srkyn%2Flapse/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/srkyn%2Flapse/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/srkyn","download_url":"https://codeload.github.com/srkyn/lapse/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/srkyn%2Flapse/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33718449,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-05-31T02:00:06.040Z","response_time":95,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["audit","azure-ad","cli","device-management","entra-id","identity","microsoft-graph","python","security"],"created_at":"2026-05-31T04:02:56.400Z","updated_at":"2026-05-31T04:02:56.473Z","avatar_url":"https://github.com/srkyn.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"![lapse project banner](docs/assets/lapse-banner.svg)\n\n# lapse\n\nEntra ID accumulates device objects silently. VDI pools register a new object every session. Offboarded employees leave phones and laptops in the directory for months. Eventually a user hits the device registration quota and gets blocked from Office 365 on a new laptop, but the directory evidence is hard to interpret.\n\nThe standard cleanup approach filters on `approximateLastSignInDateTime`. The problem is that property also updates on background sync traffic, Windows Update heartbeats, and MDM check-ins. A device untouched by a human for 18 months can still appear active. Naive filters produce hundreds of false positives and erode trust in the whole process.\n\nlapse adds a second signal. For every candidate that fails the timestamp filter, it checks `auditLogs/signIns` for actual interactive user authentication within the same window. Background sync doesn't count. A device is only marked truly stale when both signals agree.\n\n![Release](https://img.shields.io/github/v/release/srkyn/lapse?style=flat-square)\n![CI](https://img.shields.io/github/actions/workflow/status/srkyn/lapse/ci.yml?branch=main\u0026style=flat-square)\n![Python](https://img.shields.io/badge/python-3.8%2B-1f6feb?style=flat-square)\n![License](https://img.shields.io/github/license/srkyn/lapse?style=flat-square)\n\n## Demo\n\n![Sanitized lapse terminal output](docs/assets/lapse-sample-output.svg)\n\nIn a synthetic 90-device test scenario, lapse identified 34 initial stale candidates from the timestamp filter. Secondary sign-in log verification confirmed 11 as false positives — devices with background sync or MDM heartbeat traffic keeping the timestamp current despite no real user activity. The confirmed stale set was 23 devices. Without the second signal, a naive filter would have flagged 34 and generated cleanup tickets that erode trust in the process.\n\nMore context is available in [docs/demo.md](docs/demo.md).\n\n## What It Does\n\n- Queries Graph API with a server-side `$filter` on `approximateLastSignInDateTime` to pull initial candidates.\n- Cross-checks each candidate against `auditLogs/signIns` filtered to `interactiveUser` events — the secondary verification that eliminates false positives.\n- Excludes hybrid-joined (domain-joined) devices by default.\n- `--company-only` excludes personal BYOD devices.\n- `--skip-vdi` excludes non-persistent VDI registrations by name and enrollment profile.\n- `--disable` sets `accountEnabled = false` — reversible, no deletion.\n- `--delete` permanently removes stale devices, with a confirmation prompt unless `--force`.\n- `--dry-run` produces a full report with no changes made.\n- JSON and CSV output for review workflows and audit records.\n- Parallel sign-in log checks via `concurrent.futures` to keep runtime reasonable on large tenants.\n- `Retry-After` backoff on HTTP 429.\n- Token cache persisted between runs; supports device code flow and client credentials.\n\n## Decision Flow\n\n```mermaid\nflowchart LR\n    Devices[\"Graph devices\u003cbr/\u003eapproximateLastSignInDateTime older than threshold\"] --\u003e Candidates[\"Initial stale candidates\"]\n    Candidates --\u003e JoinFilter[\"Scope filters\u003cbr/\u003eexclude hybrid-joined, personal BYOD, VDI noise\"]\n    JoinFilter --\u003e SignIns[\"Interactive sign-in check\u003cbr/\u003eauditLogs/signIns, interactiveUser only\"]\n    SignIns --\u003e Report[\"Report\u003cbr/\u003eJSON, CSV, terminal summary\"]\n    Report --\u003e Action[\"Controlled action\u003cbr/\u003edry-run, disable, or confirmed delete\"]\n```\n\nThe second signal is the point of the tool: a device is not treated as truly stale solely because background activity made one timestamp confusing.\n\n## Required Permissions\n\nRegister an application in Entra ID and grant:\n\n| Permission | Why |\n|---|---|\n| `Device.ReadWrite.All` | Read device list; disable or delete. |\n| `Directory.Read.All` | Read directory properties. |\n| `AuditLog.Read.All` | Read interactive sign-in logs for secondary verification. |\n\nFor read-only audits, `Device.Read.All` is sufficient.\n\n## Usage\n\n```bash\n# Report only — no changes\nlp --client-id \u003cid\u003e --tenant-id \u003ctenant\u003e -d 90 -n\nlapse --client-id \u003cid\u003e --tenant-id \u003ctenant\u003e --days 90 --dry-run\n\n# Filter to company-owned devices, skip VDI noise\nlp --client-id \u003cid\u003e --tenant-id \u003ctenant\u003e --company-only --skip-vdi\n\n# Write reports\nlp --client-id \u003cid\u003e --tenant-id \u003ctenant\u003e -o results.json --output-csv results.csv\n\n# Disable stale devices (reversible)\nlp --client-id \u003cid\u003e --tenant-id \u003ctenant\u003e --disable\n\n# Delete stale devices (confirm first with --dry-run)\nlp --client-id \u003cid\u003e --tenant-id \u003ctenant\u003e --delete\n\n# App-only for scheduled automation\nexport LAPSE_CLIENT_SECRET=\"\u003csecret\u003e\"\nlp --client-secret --client-id \u003cid\u003e --tenant-id \u003ctenant\u003e --client-secret-env LAPSE_CLIENT_SECRET --disable\n```\n\nPowerShell equivalent:\n\n```powershell\n$env:LAPSE_CLIENT_SECRET = \"\u003csecret\u003e\"\nlp --client-secret --client-id \u003cid\u003e --tenant-id \u003ctenant\u003e --client-secret-env LAPSE_CLIENT_SECRET --disable\n```\n\n## Short Flags\n\n| Short | Long | Description |\n|---|---|---|\n| `-d N` | `--days N` | Inactivity threshold in days |\n| `-o FILE` | `--output FILE` | Write JSON report to FILE |\n| `-q` | `--quiet` | Summary line only, no table |\n| `-n` | `--dry-run` | Report without making changes |\n| `-f` | `--force` | Skip confirmation prompt on `--delete` |\n| `-w N` | `--workers N` | Parallel threads for sign-in checks |\n\n## Secret Handling\n\nPrefer `--client-secret-env` over `--client-secret-value` for scheduled runs so\nthe secret is not left in shell history or exposed in process listings. The\ntoken cache may contain reusable authentication material; keep\n`token_cache.bin` out of source control, store it in a restricted directory, and\ndelete or revoke it if it is exposed.\n\n## Deployment Stages\n\nRunning `--delete` on day one is how cleanup tools create support tickets. The recommended path:\n\n| Stage | Command | Checkpoint |\n|---|---|---|\n| Audit | `--dry-run` | Review for a week. Look for false positives. |\n| Review | `--output-csv` | Human approves before any action. |\n| Disable | `--disable` | Run two weeks. Confirm nothing legitimate is affected. |\n| Purge | `--delete` | Use only after repeated review, approval, and confirmed disable history. |\n\n## Installation\n\n```bash\ngit clone https://github.com/srkyn/lapse.git\ncd lapse\npip install .\nlp --version\n```\n\nOr run directly:\n\n```bash\npip install msal requests tabulate\npython lapse.py --client-id \u003cid\u003e --tenant-id \u003ctenant\u003e --dry-run\n```\n\n## Files\n\n- `lapse.py`: the scanner\n- `docs/demo.md`: sanitized example output and interpretation\n- `tests/test_lapse.py`: unit tests (37 cases)\n- `docs/design-notes.md`: detection approach, design decisions, and limitations\n- `CHANGELOG.md`: release history\n\n## Limitations\n\n- Sign-in log retention depends on Entra ID license tier; short retention windows reduce secondary verification accuracy.\n- Does not inspect device software or Intune compliance state.\n- Does not handle on-premises Active Directory.\n- May miss devices in tenants where the current credentials lack read access to sign-in logs.\n\n## Testing\n\n```bash\npython -m py_compile lapse.py\npython -m unittest discover -s tests -v\nlp --version\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsrkyn%2Flapse","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fsrkyn%2Flapse","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsrkyn%2Flapse/lists"}