First public release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
.npmrc
|
||||||
|
dist-cjs/
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
stages: [test]
|
||||||
|
|
||||||
|
test:
|
||||||
|
stage: test
|
||||||
|
image: node:22-alpine
|
||||||
|
script: [npm ci, npm run build, npm test]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.0 - 2026-09-28
|
||||||
|
|
||||||
|
First public release under MIT.
|
||||||
|
|
||||||
|
- `verifyTermination()` checks each enumerated resource and passes only those
|
||||||
|
confirmed gone by an independent probe.
|
||||||
|
- Absence reported by the delete call itself, assumed, or unknown is not
|
||||||
|
assessed, never a pass.
|
||||||
|
- A separate critical finding fires when compute is gone and billing is still
|
||||||
|
active.
|
||||||
|
- Resources kept under a stated retention policy are not applicable and are
|
||||||
|
still printed.
|
||||||
|
- `coverageGaps()` lists resource classes the teardown never enumerated, and
|
||||||
|
`formatTermination()` renders the report with those gaps.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Issues and merge requests are welcome at
|
||||||
|
https://gitlab.com/extant2000/kill-switch.
|
||||||
|
|
||||||
|
## Ground rules
|
||||||
|
|
||||||
|
- **A test that cannot fail proves nothing.** If you fix a bug, add a test
|
||||||
|
that fails without your fix, and check that it does fail before you submit.
|
||||||
|
- Measure, do not assume. Two modules with the same line count can be
|
||||||
|
different programs.
|
||||||
|
- Keep dependencies minimal. Every new dependency needs a reason.
|
||||||
|
- Explain why in comments, not what.
|
||||||
|
|
||||||
|
## Before you open a merge request
|
||||||
|
|
||||||
|
Run both of these and make sure they pass:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run build
|
||||||
|
npm test
|
||||||
|
```
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Extant 2000 LLC
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# KillSwitch
|
||||||
|
|
||||||
|
Checks that a terminated deployment is actually gone, resource class by
|
||||||
|
resource class, including the billing that tends to outlive it.
|
||||||
|
|
||||||
|
## What it does and why
|
||||||
|
|
||||||
|
Deleting is the easy half. The hard half is proving nothing remains, and the
|
||||||
|
two failures that matter are both invisible to the delete path itself.
|
||||||
|
|
||||||
|
### Billing outliving compute
|
||||||
|
|
||||||
|
The servers stop, the DNS record goes, and the subscription keeps charging.
|
||||||
|
Each half looks correct on its own: the teardown succeeded, and the
|
||||||
|
subscription is in a normal active state. Only the combination is wrong, and
|
||||||
|
the customer is the one who finds out. No per-resource check can see this,
|
||||||
|
so KillSwitch reports it as a separate critical finding and prints it first.
|
||||||
|
|
||||||
|
### A delete response taken as a verification
|
||||||
|
|
||||||
|
A `DELETE` that returns 200 means the API accepted the request. It does not
|
||||||
|
mean the resource is gone. A teardown whose only evidence is its own success
|
||||||
|
response has verified nothing, so an `absent` state backed only by
|
||||||
|
`delete-response` is reported as not assessed. That is the difference
|
||||||
|
between terminated and verifiably terminated.
|
||||||
|
|
||||||
|
KillSwitch never deletes anything. It takes an inventory you enumerated and
|
||||||
|
decides whether the removal can be evidenced. There is no network access and
|
||||||
|
no cloud SDK.
|
||||||
|
|
||||||
|
| `state` and `verifiedBy` | Result |
|
||||||
|
|---|---|
|
||||||
|
| `absent`, `independent-probe` | Met. The only real pass. |
|
||||||
|
| `absent`, `delete-response` | Not assessed. The API accepted a request. |
|
||||||
|
| `absent`, `assumed` or unset | Not assessed. An assumption is not a verification. |
|
||||||
|
| `unknown` | Not assessed. Nobody looked. |
|
||||||
|
| `present` with `retainedBy` | Not applicable, and still printed. |
|
||||||
|
| `present` | Not met. This is residue. |
|
||||||
|
|
||||||
|
Surviving `billing`, `secret` and `database` resources are critical, because
|
||||||
|
they cost money or can still authenticate. Other classes are high.
|
||||||
|
|
||||||
|
## Retention is an outcome, not a leak
|
||||||
|
|
||||||
|
A backup kept after termination under a stated policy is correct behaviour.
|
||||||
|
Reporting it as residue would teach people to ignore the report. Setting
|
||||||
|
`retainedBy` makes it not applicable, but it is still printed, because a
|
||||||
|
retention nobody can see looks the same as a leak.
|
||||||
|
|
||||||
|
## Coverage gaps
|
||||||
|
|
||||||
|
`coverageGaps()` lists the resource classes the teardown never mentioned. It
|
||||||
|
answers a different question from "is this resource gone". A checklist that
|
||||||
|
never mentions billing passes every check it makes. `formatTermination()`
|
||||||
|
adds these gaps to the report as a NOT COVERED note.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { verifyTermination, formatTermination, coverageGaps, type Termination } from '@extant2000/kill-switch'
|
||||||
|
|
||||||
|
const termination: Termination = {
|
||||||
|
target: 'customer example-co',
|
||||||
|
resources: [
|
||||||
|
{ id: 'web-1', class: 'compute', where: 'cloud console: instances',
|
||||||
|
state: 'absent', verifiedBy: 'independent-probe' },
|
||||||
|
{ id: 'sub_123', class: 'billing', where: 'billing API: subscriptions',
|
||||||
|
state: 'present', verifiedBy: 'independent-probe' },
|
||||||
|
{ id: 'data-vol', class: 'storage', where: 'DELETE /volumes/data-vol',
|
||||||
|
state: 'absent', verifiedBy: 'delete-response' },
|
||||||
|
{ id: 'nightly-backup', class: 'backup', where: 's3://backups/example-co',
|
||||||
|
state: 'present', retainedBy: '90-day retention, contractual' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = verifyTermination(termination) // evidence-record report
|
||||||
|
const gaps = coverageGaps(termination) // ['dns', 'route', 'database', 'mailbox', 'secret']
|
||||||
|
console.log(formatTermination(termination))
|
||||||
|
```
|
||||||
|
|
||||||
|
Output of the example above, with the header, notes and detail lines
|
||||||
|
omitted:
|
||||||
|
|
||||||
|
```
|
||||||
|
NOT MET · 4 items examined · 5 findings
|
||||||
|
|
||||||
|
✗ CRITICAL Compute is gone and billing is still active [NOT MET]
|
||||||
|
✗ CRITICAL billing sub_123 still present after termination [NOT MET]
|
||||||
|
· MEDIUM storage data-vol reported absent by the delete call itself [NOT ASSESSED]
|
||||||
|
◦ info compute web-1 confirmed gone by independent probe [MET]
|
||||||
|
```
|
||||||
|
|
||||||
|
Resource classes are `compute`, `storage`, `dns`, `route`, `database`,
|
||||||
|
`billing`, `mailbox`, `secret` and `backup`. An empty inventory is reported
|
||||||
|
as not assessed: an unenumerated resource is not a deleted one.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install @extant2000/kill-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT. See [LICENSE](LICENSE).
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Security
|
||||||
|
|
||||||
|
Please report vulnerabilities privately by email to security@extant2000.com.
|
||||||
|
Do not open a public issue for a security problem.
|
||||||
|
|
||||||
|
We aim to acknowledge every report within 5 business days.
|
||||||
Generated
+1503
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "@extant2000/kill-switch",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": false,
|
||||||
|
"description": "Checks that a terminated deployment is actually gone, including billing that outlives compute.",
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist-cjs/index.js",
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"dist-cjs",
|
||||||
|
"src",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://gitlab.com/extant2000/kill-switch.git"
|
||||||
|
},
|
||||||
|
"module": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"require": "./dist-cjs/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc && tsc -p tsconfig.cjs.json && node -e \"require('fs').writeFileSync('dist-cjs/package.json', JSON.stringify({type:'commonjs'}))\"",
|
||||||
|
"test": "vitest run",
|
||||||
|
"prepublishOnly": "npm run build"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
"vitest": "^2.1.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@extant2000/evidence-record": "^0.1.2"
|
||||||
|
},
|
||||||
|
"author": "Extant 2000 LLC",
|
||||||
|
"homepage": "https://gitlab.com/extant2000/kill-switch",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://gitlab.com/extant2000/kill-switch/-/issues"
|
||||||
|
}
|
||||||
|
}
|
||||||
+255
@@ -0,0 +1,255 @@
|
|||||||
|
/**
|
||||||
|
* KillSwitch — verify that a terminated deployment is actually gone.
|
||||||
|
*
|
||||||
|
* Deleting is the easy half. The capability is PROVING nothing remains, and
|
||||||
|
* the two failures that matter are both invisible to the delete path itself:
|
||||||
|
*
|
||||||
|
* 1. **Billing outliving compute.** The containers stop, the DNS record
|
||||||
|
* goes, and the subscription keeps charging. The customer stopped
|
||||||
|
* receiving the service and did not stop paying for it. Nothing in the
|
||||||
|
* teardown script notices, because billing lives in a different system.
|
||||||
|
*
|
||||||
|
* 2. **A delete response mistaken for a verification.** `DELETE` returning
|
||||||
|
* 200 means the API accepted the request. It does not mean the resource
|
||||||
|
* is gone — and a teardown whose only evidence is its own success
|
||||||
|
* response has verified nothing. This library treats that as UNVERIFIED,
|
||||||
|
* not as absent, which is the entire difference between "terminated" and
|
||||||
|
* "verifiably terminated".
|
||||||
|
*
|
||||||
|
* Deliberate retention is a first-class outcome, not a leak: backups that
|
||||||
|
* outlive a termination under a stated policy are correct, and reporting
|
||||||
|
* them as residue would train people to ignore the report.
|
||||||
|
*
|
||||||
|
* Pure: takes an enumerated inventory, returns findings. No network, no
|
||||||
|
* cloud SDK, no deletes — this library never removes anything, it only
|
||||||
|
* decides whether a removal can be evidenced.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type CapabilityReport,
|
||||||
|
type Finding,
|
||||||
|
type FormatOptions,
|
||||||
|
type Severity,
|
||||||
|
evidence,
|
||||||
|
formatReport as renderReport,
|
||||||
|
} from '@extant2000/evidence-record'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classes of resource a deployment leaves behind.
|
||||||
|
*
|
||||||
|
* `billing` and `secret` are listed alongside the infrastructure on purpose.
|
||||||
|
* A teardown checklist that covers only compute and storage is the one that
|
||||||
|
* produces a cancelled customer who is still being charged.
|
||||||
|
*/
|
||||||
|
export type ResourceClass =
|
||||||
|
| 'compute'
|
||||||
|
| 'storage'
|
||||||
|
| 'dns'
|
||||||
|
| 'route'
|
||||||
|
| 'database'
|
||||||
|
| 'billing'
|
||||||
|
| 'mailbox'
|
||||||
|
| 'secret'
|
||||||
|
| 'backup'
|
||||||
|
|
||||||
|
export type ResourceState =
|
||||||
|
/** Confirmed still there. */
|
||||||
|
| 'present'
|
||||||
|
/** Confirmed gone. */
|
||||||
|
| 'absent'
|
||||||
|
/** Not checked, or the check could not answer. Never counted as gone. */
|
||||||
|
| 'unknown'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How `state` was established.
|
||||||
|
*
|
||||||
|
* `delete-response` is the weak one and the common one: the teardown called
|
||||||
|
* DELETE, got a 2xx, and recorded success. That is a statement about the
|
||||||
|
* API accepting a request, not about the resource.
|
||||||
|
*/
|
||||||
|
export type VerificationMethod = 'independent-probe' | 'delete-response' | 'assumed'
|
||||||
|
|
||||||
|
export interface Resource {
|
||||||
|
id: string
|
||||||
|
class: ResourceClass
|
||||||
|
/** Where this was observed — a command, a console URL, a config path. */
|
||||||
|
where: string
|
||||||
|
state: ResourceState
|
||||||
|
verifiedBy?: VerificationMethod
|
||||||
|
/**
|
||||||
|
* A stated policy that legitimately keeps this alive past termination,
|
||||||
|
* e.g. "90-day backup retention, contractual". Presence WITH a retention
|
||||||
|
* reason is correct behaviour, not residue.
|
||||||
|
*/
|
||||||
|
retainedBy?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Termination {
|
||||||
|
/** What was terminated, e.g. "customer acme-corp". */
|
||||||
|
target: string
|
||||||
|
resources: Resource[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Classes whose survival is a security or money problem, not just untidiness. */
|
||||||
|
const CRITICAL_CLASSES: ResourceClass[] = ['billing', 'secret', 'database']
|
||||||
|
|
||||||
|
function resourceEvidence(r: Resource) {
|
||||||
|
return evidence.file(r.where, undefined, `${r.class}: ${r.state}`,
|
||||||
|
r.verifiedBy ? `verified by ${r.verifiedBy}` : 'no verification method recorded')
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityFor(r: Resource): Severity {
|
||||||
|
return CRITICAL_CLASSES.includes(r.class) ? 'critical' : 'high'
|
||||||
|
}
|
||||||
|
|
||||||
|
function findingFor(r: Resource): Finding {
|
||||||
|
const base = { id: `${r.class}:${r.id}`, evidence: [resourceEvidence(r)] }
|
||||||
|
|
||||||
|
if (r.state === 'present' && r.retainedBy) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
summary: `${r.class} ${r.id} still present, retained deliberately`,
|
||||||
|
determination: 'not-applicable',
|
||||||
|
severity: 'info',
|
||||||
|
detail: `Retained by: ${r.retainedBy}. This is policy, not residue — but it is printed, because a retention nobody can see is indistinguishable from a leak.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r.state === 'present') {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
summary: `${r.class} ${r.id} still present after termination`,
|
||||||
|
determination: 'fail',
|
||||||
|
severity: severityFor(r),
|
||||||
|
detail: r.class === 'billing'
|
||||||
|
? 'The subscription is still active. The customer has stopped receiving the service and has not stopped paying for it.'
|
||||||
|
: r.class === 'secret'
|
||||||
|
? 'A credential outlived the deployment it belonged to. It can still authenticate.'
|
||||||
|
: 'No retention policy explains this. It is residue.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r.state === 'unknown') {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
summary: `${r.class} ${r.id} was not checked`,
|
||||||
|
determination: 'not-assessed',
|
||||||
|
severity: severityFor(r),
|
||||||
|
detail: 'A resource nobody looked at is not a resource that was removed.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// state === 'absent' — but on whose word?
|
||||||
|
if (r.verifiedBy === 'delete-response') {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
summary: `${r.class} ${r.id} reported absent by the delete call itself`,
|
||||||
|
determination: 'not-assessed',
|
||||||
|
severity: 'medium',
|
||||||
|
detail: 'A 2xx from DELETE means the API accepted the request, not that the resource is gone. Re-check it from outside the teardown path.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r.verifiedBy === 'assumed' || !r.verifiedBy) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
summary: `${r.class} ${r.id} assumed absent, with no check recorded`,
|
||||||
|
determination: 'not-assessed',
|
||||||
|
severity: severityFor(r),
|
||||||
|
detail: 'Nothing observed this resource. An assumption is not a verification.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
summary: `${r.class} ${r.id} confirmed gone by independent probe`,
|
||||||
|
determination: 'pass',
|
||||||
|
severity: 'info',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cross-cutting check no per-resource rule can make.
|
||||||
|
*
|
||||||
|
* Compute gone + billing alive is the failure that costs money and trust,
|
||||||
|
* and it is invisible to both halves individually: the infrastructure
|
||||||
|
* teardown succeeded, and the subscription is in a perfectly normal active
|
||||||
|
* state. Only the combination is wrong.
|
||||||
|
*/
|
||||||
|
function billingOutlivesCompute(t: Termination): Finding | null {
|
||||||
|
const computeGone = t.resources
|
||||||
|
.filter(r => r.class === 'compute')
|
||||||
|
.every(r => r.state === 'absent')
|
||||||
|
const hasCompute = t.resources.some(r => r.class === 'compute')
|
||||||
|
|
||||||
|
const liveBilling = t.resources.filter(
|
||||||
|
r => r.class === 'billing' && r.state === 'present' && !r.retainedBy,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!hasCompute || !computeGone || liveBilling.length === 0) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'billing-outlives-compute',
|
||||||
|
summary: 'Compute is gone and billing is still active',
|
||||||
|
determination: 'fail',
|
||||||
|
severity: 'critical',
|
||||||
|
detail: 'Each half looks correct on its own: the teardown succeeded, and the subscription is in a normal active state. Only the combination is wrong, and the customer is the one who finds out.',
|
||||||
|
evidence: liveBilling.map(resourceEvidence),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check whether a termination can be evidenced, class by class. */
|
||||||
|
export function verifyTermination(t: Termination): CapabilityReport {
|
||||||
|
const resources = t.resources ?? []
|
||||||
|
|
||||||
|
if (resources.length === 0) {
|
||||||
|
return {
|
||||||
|
capability: 'KillSwitch',
|
||||||
|
scope: `${t.target} — nothing enumerated`,
|
||||||
|
examined: 0,
|
||||||
|
findings: [],
|
||||||
|
notes: ['No resources were enumerated, so nothing was verified. An unenumerated resource is not a deleted one.'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cross = billingOutlivesCompute(t)
|
||||||
|
return {
|
||||||
|
capability: 'KillSwitch',
|
||||||
|
scope: `${t.target} — ${resources.length} resource(s)`,
|
||||||
|
examined: resources.length,
|
||||||
|
findings: [...(cross ? [cross] : []), ...resources.map(findingFor)],
|
||||||
|
notes: [`classes covered: ${[...new Set(resources.map(r => r.class))].sort().join(', ')}`],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classes present in the termination, against the classes a complete
|
||||||
|
* teardown should cover.
|
||||||
|
*
|
||||||
|
* Reported separately from the resource findings because it is a different
|
||||||
|
* question: not "is this resource gone" but "did anyone think about DNS at
|
||||||
|
* all". A checklist that never mentions billing passes every check it makes.
|
||||||
|
*/
|
||||||
|
export function coverageGaps(t: Termination, expected: ResourceClass[] = ALL_CLASSES): ResourceClass[] {
|
||||||
|
const seen = new Set((t.resources ?? []).map(r => r.class))
|
||||||
|
return expected.filter(c => !seen.has(c))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ALL_CLASSES: ResourceClass[] = [
|
||||||
|
'compute', 'storage', 'dns', 'route', 'database', 'billing', 'mailbox', 'secret', 'backup',
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Render a termination check for a terminal, CI log or evidence package. */
|
||||||
|
export function formatTermination(t: Termination, opts: FormatOptions = {}): string {
|
||||||
|
const report = verifyTermination(t)
|
||||||
|
const gaps = coverageGaps(t)
|
||||||
|
if (gaps.length > 0 && (t.resources ?? []).length > 0) {
|
||||||
|
report.notes = [
|
||||||
|
...(report.notes ?? []),
|
||||||
|
`NOT COVERED: ${gaps.join(', ')} — a class nobody enumerated cannot fail a check.`,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return renderReport(report, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CRITICAL_CLASSES }
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { overall, validateReport } from '@extant2000/evidence-record'
|
||||||
|
import {
|
||||||
|
coverageGaps,
|
||||||
|
formatTermination,
|
||||||
|
verifyTermination,
|
||||||
|
type Resource,
|
||||||
|
type Termination,
|
||||||
|
} from '../src/index.js'
|
||||||
|
|
||||||
|
const gone = (id: string, cls: Resource['class']): Resource =>
|
||||||
|
({ id, class: cls, where: `probe:${id}`, state: 'absent', verifiedBy: 'independent-probe' })
|
||||||
|
|
||||||
|
describe('billing outliving compute — the failure that costs money', () => {
|
||||||
|
const t: Termination = {
|
||||||
|
target: 'customer acme-corp',
|
||||||
|
resources: [
|
||||||
|
gone('acme-web', 'compute'),
|
||||||
|
gone('acme.example.com', 'dns'),
|
||||||
|
{ id: 'sub_1ABC', class: 'billing', where: 'stripe:sub_1ABC', state: 'present', verifiedBy: 'independent-probe' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
it('catches the combination neither half can see', () => {
|
||||||
|
// The teardown succeeded. The subscription is in a normal active state.
|
||||||
|
// Only together are they wrong.
|
||||||
|
const r = verifyTermination(t)
|
||||||
|
const cross = r.findings.find(f => f.id === 'billing-outlives-compute')!
|
||||||
|
expect(cross.determination).toBe('fail')
|
||||||
|
expect(cross.severity).toBe('critical')
|
||||||
|
expect(overall(r)).toBe('fail')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leads with it, above the per-resource findings', () => {
|
||||||
|
const text = formatTermination(t)
|
||||||
|
expect(text.indexOf('Compute is gone and billing is still active'))
|
||||||
|
.toBeLessThan(text.indexOf('sub_1ABC still present'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says who finds out', () => {
|
||||||
|
expect(formatTermination(t)).toContain('the customer is the one who finds out')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fire when compute is still up — that is just a running service', () => {
|
||||||
|
const running: Termination = {
|
||||||
|
target: 'x',
|
||||||
|
resources: [
|
||||||
|
{ id: 'web', class: 'compute', where: 'docker ps', state: 'present' },
|
||||||
|
{ id: 'sub_1', class: 'billing', where: 'stripe', state: 'present' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
expect(verifyTermination(running).findings.some(f => f.id === 'billing-outlives-compute')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fire when the subscription is deliberately retained', () => {
|
||||||
|
const retained: Termination = {
|
||||||
|
target: 'x',
|
||||||
|
resources: [
|
||||||
|
gone('web', 'compute'),
|
||||||
|
{ id: 'sub_1', class: 'billing', where: 'stripe', state: 'present', retainedBy: 'final invoice pending' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
expect(verifyTermination(retained).findings.some(f => f.id === 'billing-outlives-compute')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('a delete response is not a verification', () => {
|
||||||
|
it('treats absence claimed by the delete call as UNVERIFIED, not gone', () => {
|
||||||
|
// A 2xx from DELETE means the API accepted the request. This is the
|
||||||
|
// entire difference between "terminated" and "verifiably terminated".
|
||||||
|
const r = verifyTermination({
|
||||||
|
target: 'x',
|
||||||
|
resources: [{ id: 'vol-1', class: 'storage', where: 'DELETE /volumes/vol-1', state: 'absent', verifiedBy: 'delete-response' }],
|
||||||
|
})
|
||||||
|
expect(r.findings[0]!.determination).toBe('not-assessed')
|
||||||
|
expect(overall(r)).toBe('not-assessed')
|
||||||
|
expect(formatTermination({
|
||||||
|
target: 'x',
|
||||||
|
resources: [{ id: 'vol-1', class: 'storage', where: 'DELETE /volumes/vol-1', state: 'absent', verifiedBy: 'delete-response' }],
|
||||||
|
})).toContain('Re-check it from outside the teardown path')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats an unrecorded method as an assumption, never a pass', () => {
|
||||||
|
const r = verifyTermination({
|
||||||
|
target: 'x',
|
||||||
|
resources: [{ id: 'v', class: 'storage', where: 'runbook.md', state: 'absent' }],
|
||||||
|
})
|
||||||
|
expect(r.findings[0]!.determination).toBe('not-assessed')
|
||||||
|
expect(r.findings[0]!.detail).toContain('An assumption is not a verification')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes only on an independent probe', () => {
|
||||||
|
expect(overall(verifyTermination({ target: 'x', resources: [gone('v', 'storage')] }))).toBe('pass')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never counts unknown as gone', () => {
|
||||||
|
const r = verifyTermination({
|
||||||
|
target: 'x',
|
||||||
|
resources: [{ id: 'k', class: 'secret', where: 'vault', state: 'unknown' }],
|
||||||
|
})
|
||||||
|
expect(r.findings[0]!.determination).toBe('not-assessed')
|
||||||
|
expect(r.findings[0]!.severity).toBe('critical')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('retention is an outcome, not a leak', () => {
|
||||||
|
const t: Termination = {
|
||||||
|
target: 'x',
|
||||||
|
resources: [{
|
||||||
|
id: 'backup-2026-07', class: 'backup', where: 'b2://bucket/acme',
|
||||||
|
state: 'present', retainedBy: '90-day retention, contractual',
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
it('reports a policy-retained resource as not-applicable, not a failure', () => {
|
||||||
|
expect(verifyTermination(t).findings[0]!.determination).toBe('not-applicable')
|
||||||
|
expect(overall(verifyTermination(t))).toBe('not-applicable')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still prints it, because an invisible retention looks like a leak', () => {
|
||||||
|
expect(formatTermination(t)).toContain('90-day retention, contractual')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('residue', () => {
|
||||||
|
it('rates a surviving secret critical, and says it can still authenticate', () => {
|
||||||
|
const r = verifyTermination({
|
||||||
|
target: 'x',
|
||||||
|
resources: [{ id: 'API_KEY', class: 'secret', where: '.env', state: 'present' }],
|
||||||
|
})
|
||||||
|
expect(r.findings[0]!.severity).toBe('critical')
|
||||||
|
expect(r.findings[0]!.detail).toContain('can still authenticate')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rates a surviving DNS record high rather than critical', () => {
|
||||||
|
const r = verifyTermination({
|
||||||
|
target: 'x',
|
||||||
|
resources: [{ id: 'a.example.com', class: 'dns', where: 'cf', state: 'present' }],
|
||||||
|
})
|
||||||
|
expect(r.findings[0]!.severity).toBe('high')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('coverage — a class nobody enumerated cannot fail a check', () => {
|
||||||
|
it('names the classes the teardown never mentioned', () => {
|
||||||
|
const t: Termination = { target: 'x', resources: [gone('web', 'compute'), gone('v', 'storage')] }
|
||||||
|
expect(coverageGaps(t)).toContain('billing')
|
||||||
|
expect(coverageGaps(t)).toContain('secret')
|
||||||
|
expect(formatTermination(t)).toContain('NOT COVERED')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports no gaps when every class is enumerated', () => {
|
||||||
|
const t: Termination = {
|
||||||
|
target: 'x',
|
||||||
|
resources: (['compute', 'storage', 'dns', 'route', 'database', 'billing', 'mailbox', 'secret', 'backup'] as const)
|
||||||
|
.map(c => gone(c, c)),
|
||||||
|
}
|
||||||
|
expect(coverageGaps(t)).toEqual([])
|
||||||
|
expect(formatTermination(t)).not.toContain('NOT COVERED')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('conformance with the evidence-record standard', () => {
|
||||||
|
it('an empty termination is not-assessed, never clean', () => {
|
||||||
|
const r = verifyTermination({ target: 'x', resources: [] })
|
||||||
|
expect(overall(r)).toBe('not-assessed')
|
||||||
|
expect(formatTermination({ target: 'x', resources: [] }))
|
||||||
|
.toContain('An unenumerated resource is not a deleted one')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('every conclusion carries a citation', () => {
|
||||||
|
const t: Termination = {
|
||||||
|
target: 'x',
|
||||||
|
resources: [
|
||||||
|
gone('web', 'compute'),
|
||||||
|
{ id: 'sub_1', class: 'billing', where: 'stripe', state: 'present' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
expect(validateReport(verifyTermination(t))).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"outDir": "dist-cjs",
|
||||||
|
"declaration": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022", "module": "ES2022", "moduleResolution": "bundler",
|
||||||
|
"declaration": true, "outDir": "dist", "rootDir": "src",
|
||||||
|
"strict": true, "skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user