@caddis/cli 0.4.23 → 0.4.24
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.
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/_registry.md +1 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/SKILL.md +174 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/profiles/_template/profile.json +55 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/profiles/batch-job/profile.json +68 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/profiles/dotnet-service/profile.json +67 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/profiles/java-service/profile.json +62 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/profiles/node-service/profile.json +66 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/profiles/php-db/profile.json +75 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/profiles/python-web/profile.json +73 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/profiles/static-site/profile.json +87 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/references/config.example.json +18 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/references/profile-contract.md +207 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/references/standard.md +304 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/scripts/AdoptCommon.ps1 +812 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/scripts/Get-AppInventory.ps1 +236 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/scripts/New-AdoptionPlan.ps1 +311 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/scripts/Resolve-AppProfile.ps1 +246 -0
- package/bundles/antigravity-plugin-extras/skills/adopt-app/scripts/Test-AppConformance.ps1 +343 -0
- package/bundles/manifest.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS
|
|
3
|
+
Shared helpers for the adopt-app skill. Dot-source this file.
|
|
4
|
+
|
|
5
|
+
.DESCRIPTION
|
|
6
|
+
Two rules shape everything here.
|
|
7
|
+
|
|
8
|
+
1. NOTHING IN THIS SKILL EXECUTES THE APP. An app that was never bootstrapped is
|
|
9
|
+
usually the only copy of something that is running in production. We read files
|
|
10
|
+
and file metadata. We never run an entry point, an installer, a migration or a
|
|
11
|
+
build, and we never write inside the source folder.
|
|
12
|
+
|
|
13
|
+
2. A PARTIAL ANSWER MUST NOT READ AS A CLEAN ANSWER. The scan walks trees that can
|
|
14
|
+
be hundreds of gigabytes over a network share. When it runs out of budget or is
|
|
15
|
+
denied access it says so, reports a MINIMUM size rather than a wrong total, and
|
|
16
|
+
the caller fails unless the operator accepted the partial scan.
|
|
17
|
+
|
|
18
|
+
The scan itself is a single scriptblock so that one implementation serves both a
|
|
19
|
+
local folder and a remote box over WinRM. That block must stay Windows PowerShell
|
|
20
|
+
5.1 compatible: no ternary, no null-coalescing, no `Join-Path` with three arguments,
|
|
21
|
+
no `-AsHashtable`. It is ASCII only.
|
|
22
|
+
#>
|
|
23
|
+
|
|
24
|
+
Set-StrictMode -Version 2.0
|
|
25
|
+
|
|
26
|
+
# Every key here is READ by something. An earlier version also carried `contentPolicy` and a
|
|
27
|
+
# `blockingItems` list that no script ever consulted, and the list had drifted out of step
|
|
28
|
+
# with the real blocking set - so a project could set it, see no effect, and never find out.
|
|
29
|
+
# Config that does nothing is worse than no config: it is a promise the code does not keep.
|
|
30
|
+
$script:AdoptDefaultConfig = [ordered]@{
|
|
31
|
+
# Read by Resolve-AdoptOutDir.
|
|
32
|
+
outDir = '.caddis/adopt'
|
|
33
|
+
# Read by Get-AppInventory.ps1 when -FileBudget is not passed.
|
|
34
|
+
fileBudget = 20000
|
|
35
|
+
# Read by Get-AppInventory.ps1 when -SkipFolder is not passed.
|
|
36
|
+
skipFolders = @()
|
|
37
|
+
# Default for Invoke-AdoptScan -SkipPattern: folders whose contents are never classified.
|
|
38
|
+
skipPattern = '(?i)[\\/](\.git|node_modules|\.venv|venv|__pycache__|\.mypy_cache|\.pytest_cache|\.tox|dist|build|target|bin|obj|vendor|packages|\.terraform|\.gradle|\.next|\.nuxt|coverage)([\\/]|$)'
|
|
39
|
+
# Read by Test-AppConformance.ps1: the regex that says what "the team's host" means.
|
|
40
|
+
# Empty means unknown, and the remote check then says so instead of guessing.
|
|
41
|
+
expectedRemotePattern = ''
|
|
42
|
+
# Read by Test-AppConformance.ps1: a folder neither committed nor ignored that is at
|
|
43
|
+
# least this big is a finding.
|
|
44
|
+
untrackedThresholdMB = 10
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# Files whose NAME alone says "this is a credential". Matching the name is enough to flag.
|
|
48
|
+
$script:AdoptSecretNamePattern = '(?i)(^|[\\/])(\.env(\.[a-z0-9_-]+)?|.*\.pem|.*\.key|.*\.pfx|.*\.p12|.*\.jks|.*\.keystore|id_rsa|id_ed25519|.*\.ppk|credentials|\.npmrc|\.pypirc|\.netrc|secrets?\.(json|ya?ml|ini|toml|txt)|.*[._-]secrets?\.(json|ya?ml|ini|toml)|appsettings\.(Production|Prod)\.json)$'
|
|
49
|
+
|
|
50
|
+
# A file whose whole purpose is to show the SHAPE of a secret file. `.env.example` is
|
|
51
|
+
# committed on purpose in most repositories; flagging it teaches the reader to skim.
|
|
52
|
+
$script:AdoptSecretNameExemptPattern = '(?i)[._-](example|sample|template|dist|placeholder|default)$'
|
|
53
|
+
|
|
54
|
+
# A container that may hold a private key or may hold a public certificate. Which one it
|
|
55
|
+
# is decides whether it is a finding, and only the content says.
|
|
56
|
+
$script:AdoptKeyContainerPattern = '(?i)\.(pem|key)$'
|
|
57
|
+
|
|
58
|
+
# Lines whose SHAPE says "this is a credential value". Matched ONE LINE AT A TIME, because
|
|
59
|
+
# a whole-file regex cannot tell code from the prose in a comment about code.
|
|
60
|
+
#
|
|
61
|
+
# Precision is not a nicety here. The first version flagged three lines in a scripts folder
|
|
62
|
+
# and all three were noise: `$Token = Read-Host -AsSecureString ...`, and two sentences in
|
|
63
|
+
# comments that happened to contain "token=True". A check that cries wolf on every script
|
|
64
|
+
# gets switched off, which is the same failure as a check that cannot fire - approached
|
|
65
|
+
# from the other side.
|
|
66
|
+
# The value is captured as ONE token - a quoted string or a run of non-delimiter
|
|
67
|
+
# characters - never as "the rest of the line". A greedy `.*$` swallowed the remainder of
|
|
68
|
+
# every statement it matched, so `Token = '?'; Verdict = 'NO CHECKOUT' }` arrived as a
|
|
69
|
+
# 30-character "credential" and passed every length test.
|
|
70
|
+
$script:AdoptValueToken = '(?<val>"[^"\r\n]*"|''[^''\r\n]*''|[^\s,;)\]}]+)'
|
|
71
|
+
|
|
72
|
+
$script:AdoptSecretContentPatterns = @(
|
|
73
|
+
'(?i)\b(password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|connection[_-]?string)\b\s*[:=]{1,2}>?\s*' + $script:AdoptValueToken
|
|
74
|
+
# SimpleSAMLphp and friends write a user list as 'user:password' => array(...). The key
|
|
75
|
+
# must itself carry the `user:pass` shape; without that colon this matched EVERY PHP
|
|
76
|
+
# array literal, and a vendored ITSM tree produced findings like key 'css_classes'.
|
|
77
|
+
'(?i)''[^'':\r\n]{2,40}:(?<val>[^''\r\n]{4,60})''\s*=>\s*(array|\[)'
|
|
78
|
+
'(?<val>-----BEGIN [A-Z ]*PRIVATE KEY-----)'
|
|
79
|
+
'(?i)\b(?<val>gh[pousr]_[A-Za-z0-9]{16,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{20,})\b'
|
|
80
|
+
'(?i)(server|data source)\s*=\s*[^;]+;[^\r\n]*\b(password|pwd)\s*=\s*' + $script:AdoptValueToken
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# A line that starts a comment. Prose about a credential is not a credential.
|
|
84
|
+
$script:AdoptCommentLinePattern = '^\s*(#|//|--|;|\*|<#|/\*|"""|'''''')'
|
|
85
|
+
|
|
86
|
+
# A value that is NOT a literal secret: a variable, an environment lookup, a prompt, a
|
|
87
|
+
# boolean, or a placeholder somebody left for a real value.
|
|
88
|
+
$script:AdoptNonLiteralValuePattern = '(?i)^\s*(\$|%|@|\{|<|\(|`|\\|env[:.]|os\.environ|getenv|process\.env|secrets\.|vault|Read-Host|Get-Credential|ConvertTo-SecureString|true|false|null|none|nil|""|''''|\[|-|\.\.\.|xxx+|changeme|your[-_]|replace[-_]?me|example|placeholder|todo|fixme|\*{3,})' +
|
|
89
|
+
# A TYPE, not a value. `password: string` in an interface and `secret: string` in a
|
|
90
|
+
# function signature are declarations; a scan that reads them as credentials reports a
|
|
91
|
+
# finding on every typed codebase.
|
|
92
|
+
'|(?i)^\s*(string|number|boolean|int|integer|bool|str|text|varchar|char|any|unknown|object|array|bytes|float|double|decimal|uuid|date|datetime)\s*[;,)\]}]?\s*$'
|
|
93
|
+
|
|
94
|
+
# A value that REFERS to a secret rather than being one. `${{ secrets.X }}`, `%VAR%`,
|
|
95
|
+
# `{token}` and a regex fragment are all references, wherever they appear in the value.
|
|
96
|
+
#
|
|
97
|
+
# A value containing `(` is a call - `readFileSync(...)`, `this.secrets.resolve(...)` - and
|
|
98
|
+
# a dotted identifier is a lookup - `config.ITOBFF_DB_PASSWORD`. Both READ a credential;
|
|
99
|
+
# neither IS one.
|
|
100
|
+
$script:AdoptReferenceValuePattern = '(\$\{|\{\{|\\[sdwbn]|%[A-Za-z_][A-Za-z0-9_]*%|\{[A-Za-z_][A-Za-z0-9_]*\}|\(|^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_])'
|
|
101
|
+
|
|
102
|
+
# Below this length a value cannot be a real credential, and above the noise floor it is
|
|
103
|
+
# almost always a flag, an index or a single-letter test fixture.
|
|
104
|
+
$script:AdoptMinSecretLength = 6
|
|
105
|
+
|
|
106
|
+
# Content that belongs on a machine, not in a repository. Category drives the advice, so
|
|
107
|
+
# keep them separate rather than folding them into one "junk" bucket.
|
|
108
|
+
$script:AdoptExcludablePatterns = @(
|
|
109
|
+
@{ category = 'dependency'; pattern = '(?i)(^|[\\/])(node_modules|vendor|\.venv|venv|site-packages|packages|bower_components|\.gradle|\.m2)([\\/]|$)' }
|
|
110
|
+
@{ category = 'runtime'; pattern = '(?i)(^|[\\/])(tools|runtimes?|jdk[0-9._-]*|jre[0-9._-]*|php[0-9._-]*|python[0-9._-]*|node-v[0-9]|mariadb[0-9._-]*|mysql[0-9._-]*|apache[0-9._-]*|nginx[0-9._-]*|tomcat[0-9._-]*)([\\/]|$)' }
|
|
111
|
+
@{ category = 'build-output'; pattern = '(?i)(^|[\\/])(dist|build|out|target|bin|obj|\.next|\.nuxt|publish)([\\/]|$)' }
|
|
112
|
+
@{ category = 'cache'; pattern = '(?i)(^|[\\/])(__pycache__|\.mypy_cache|\.ruff_cache|\.pytest_cache|\.tox|\.cache|\.parcel-cache|\.eslintcache|htmlcov|coverage)([\\/]|$)' }
|
|
113
|
+
@{ category = 'db-data'; pattern = '(?i)(\.mdf|\.ldf|\.ibd|\.frm|\.myd|\.myi|\.sqlite3?|\.db3|[\\/]data[\\/].*\.(ib_logfile[0-9]+|ibdata[0-9]+))$' }
|
|
114
|
+
@{ category = 'archive'; pattern = '(?i)\.(zip|7z|rar|tar|tgz|gz|bz2|xz|iso|msi|exe|dll|so|dylib|jar|war|ear|nupkg|whl)$' }
|
|
115
|
+
@{ category = 'data-export'; pattern = '(?i)\.(csv|tsv|parquet|avro|xlsx?|bak|dmp|dump|sql\.gz|pdf|afp|ps)$' }
|
|
116
|
+
@{ category = 'media'; pattern = '(?i)\.(mp4|mov|avi|mkv|wav|mp3|psd|ai|sketch)$' }
|
|
117
|
+
@{ category = 'log'; pattern = '(?i)([\\/]logs?[\\/]|\.log(\.[0-9]+)?$)' }
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# --------------------------------------------------------------------------------------
|
|
121
|
+
# The scan. Runs locally or inside Invoke-Command. Windows PowerShell 5.1 compatible.
|
|
122
|
+
# --------------------------------------------------------------------------------------
|
|
123
|
+
$script:AdoptScanBlock = {
|
|
124
|
+
param($Path, $FileBudget, $SkipPattern, $SecretNamePattern, $SecretContentPatterns, $ExcludableSpecs, $MaxProbeBytes, $CommentLinePattern, $NonLiteralValuePattern, $ProbeBudget, $SkipFolders, $ReferenceValuePattern, $MinSecretLength, $SecretNameExemptPattern, $KeyContainerPattern, $DetectPathCap)
|
|
125
|
+
|
|
126
|
+
$ErrorActionPreference = 'Continue'
|
|
127
|
+
|
|
128
|
+
$result = @{
|
|
129
|
+
path = $Path
|
|
130
|
+
exists = $false
|
|
131
|
+
partial = $false
|
|
132
|
+
partialReason = @()
|
|
133
|
+
folders = @()
|
|
134
|
+
manifests = @()
|
|
135
|
+
entryPoints = @()
|
|
136
|
+
configFiles = @()
|
|
137
|
+
secretFiles = @()
|
|
138
|
+
certificates = @()
|
|
139
|
+
paths = @()
|
|
140
|
+
excludable = @()
|
|
141
|
+
reparse = @()
|
|
142
|
+
testFiles = 0
|
|
143
|
+
testMarkers = @()
|
|
144
|
+
ciFiles = @()
|
|
145
|
+
docFiles = @()
|
|
146
|
+
healthHits = @()
|
|
147
|
+
totalFiles = 0
|
|
148
|
+
totalMB = 0.0
|
|
149
|
+
probed = 0
|
|
150
|
+
git = @{ isRepo = $false }
|
|
151
|
+
}
|
|
152
|
+
$probeCandidates = New-Object System.Collections.ArrayList
|
|
153
|
+
$keyContainers = New-Object System.Collections.ArrayList
|
|
154
|
+
$detectPaths = New-Object System.Collections.ArrayList
|
|
155
|
+
|
|
156
|
+
# Files whose CONTENT is worth reading. A credential hides in application source at
|
|
157
|
+
# least as often as in a config file: the first version of this scan probed only
|
|
158
|
+
# manifests and config, and walked straight past an API key assigned in settings.py.
|
|
159
|
+
$textExtensions = @('.py', '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.php', '.rb', '.go',
|
|
160
|
+
'.java', '.kt', '.scala', '.cs', '.vb', '.ps1', '.psm1', '.psd1', '.sh', '.bash', '.bat',
|
|
161
|
+
'.cmd', '.sql', '.json', '.yml', '.yaml', '.xml', '.ini', '.cfg', '.conf', '.config',
|
|
162
|
+
'.toml', '.env', '.properties', '.txt', '.md', '.tf', '.tfvars', '.gradle', '.pl', '.r',
|
|
163
|
+
'.aspx', '.jsp', '.html', '.htm', '.css', '.scss', '.tpl', '.twig', '.erb', '.j2')
|
|
164
|
+
|
|
165
|
+
if (-not (Test-Path -LiteralPath $Path)) {
|
|
166
|
+
$result.partialReason += "path not found: $Path"
|
|
167
|
+
return $result
|
|
168
|
+
}
|
|
169
|
+
$result.exists = $true
|
|
170
|
+
$root = (Resolve-Path -LiteralPath $Path).ProviderPath.TrimEnd('\')
|
|
171
|
+
|
|
172
|
+
# ---- git state, read only -------------------------------------------------------
|
|
173
|
+
# Is git even here? On a remote box it often is not, and without this test every git
|
|
174
|
+
# call returns nothing, every exit code is non-zero, and the folder classifier reported
|
|
175
|
+
# ALL TWELVE folders of a fully tracked repository as "neither committed nor ignored" -
|
|
176
|
+
# the most alarming verdict it has, produced entirely by a missing executable.
|
|
177
|
+
#
|
|
178
|
+
# `git --version` is not enough. git also refuses a repository whose files belong to
|
|
179
|
+
# another account - "detected dubious ownership" - which is the normal case for a folder
|
|
180
|
+
# on a server reached over WinRM. Every command then exits non-zero, and the scan
|
|
181
|
+
# reported a repository with no remotes, no commits and no tracked folders at all.
|
|
182
|
+
# `-c safe.directory=*` is scoped to these read-only commands and changes nothing on disk.
|
|
183
|
+
$gitAvailable = $false
|
|
184
|
+
$gitError = ''
|
|
185
|
+
& git --version 2>&1 | Out-Null
|
|
186
|
+
if ($LASTEXITCODE -ne 0) { $gitError = 'git is not installed where the scan ran' }
|
|
187
|
+
else {
|
|
188
|
+
$probe = & git -c safe.directory=* -C $root rev-parse --is-inside-work-tree 2>&1
|
|
189
|
+
if ($LASTEXITCODE -eq 0) { $gitAvailable = $true }
|
|
190
|
+
else { $gitError = "git cannot read this repository: $(@($probe) -join ' ')" }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (Test-Path -LiteralPath (Join-Path $root '.git')) {
|
|
194
|
+
# Every field is initialised and the record is attached NOW, so a caller never reads
|
|
195
|
+
# a key that is missing because one git call did not run.
|
|
196
|
+
$g = @{ isRepo = $true; gitAvailable = $gitAvailable; gitError = $gitError; commits = $null; branch = $null
|
|
197
|
+
localBranches = 0; remoteBranches = 0; tags = 0; dirtyFiles = $null
|
|
198
|
+
remotes = @(); upstream = $null; unpushed = $null; lastCommit = $null }
|
|
199
|
+
$result.git = $g
|
|
200
|
+
if (-not $gitAvailable) {
|
|
201
|
+
$result.partial = $true
|
|
202
|
+
$result.partialReason += "$gitError - so branches, remotes and which folders are tracked could not be read"
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if ((Test-Path -LiteralPath (Join-Path $root '.git')) -and $gitAvailable) {
|
|
206
|
+
$runGit = {
|
|
207
|
+
param($argLine)
|
|
208
|
+
$out = & git -c safe.directory=* -C $root -c core.longpaths=true @argLine 2>&1
|
|
209
|
+
if ($LASTEXITCODE -ne 0) { return $null }
|
|
210
|
+
return $out
|
|
211
|
+
}
|
|
212
|
+
$g.remotes = @()
|
|
213
|
+
$r = & $runGit @('remote', '-v')
|
|
214
|
+
if ($r) { foreach ($line in @($r)) { if ("$line" -match '^(\S+)\s+(\S+)\s+\(fetch\)') { $g.remotes += @{ name = $Matches[1]; url = $Matches[2] } } } }
|
|
215
|
+
$b = & $runGit @('rev-parse', '--abbrev-ref', 'HEAD'); if ($b) { $g.branch = "$b" }
|
|
216
|
+
$lb = & $runGit @('for-each-ref', '--format=%(refname:short)', 'refs/heads'); $g.localBranches = @($lb | Where-Object { "$_" -ne '' }).Count
|
|
217
|
+
$rb = & $runGit @('for-each-ref', '--format=%(refname:short)', 'refs/remotes'); $g.remoteBranches = @($rb | Where-Object { "$_" -ne '' }).Count
|
|
218
|
+
$tg = & $runGit @('tag', '--list'); $g.tags = @($tg | Where-Object { "$_" -ne '' }).Count
|
|
219
|
+
$st = & $runGit @('status', '--porcelain'); $g.dirtyFiles = @($st | Where-Object { "$_" -ne '' }).Count
|
|
220
|
+
$lc = & $runGit @('log', '-1', '--format=%H|%an|%aI|%s')
|
|
221
|
+
if ($lc) {
|
|
222
|
+
$parts = "$lc" -split '\|', 4
|
|
223
|
+
$g.lastCommit = @{ sha = $parts[0]; author = $parts[1]; date = $parts[2]; subject = $parts[3] }
|
|
224
|
+
}
|
|
225
|
+
$cc = & $runGit @('rev-list', '--count', 'HEAD'); if ($cc) { $g.commits = [int]"$cc" }
|
|
226
|
+
$up = & $runGit @('rev-parse', '--abbrev-ref', '@{upstream}')
|
|
227
|
+
if ($up) {
|
|
228
|
+
$g.upstream = "$up"
|
|
229
|
+
$ahead = & $runGit @('rev-list', '--count', "$up..HEAD")
|
|
230
|
+
if ($ahead) { $g.unpushed = [int]"$ahead" }
|
|
231
|
+
}
|
|
232
|
+
else { $g.upstream = $null; $g.unpushed = $null }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
# ---- one bounded walk per top-level folder --------------------------------------
|
|
236
|
+
$tops = @()
|
|
237
|
+
try { $tops = @(Get-ChildItem -LiteralPath $root -Force -ErrorAction Stop | Where-Object { $_.PSIsContainer }) }
|
|
238
|
+
catch { $result.partial = $true; $result.partialReason += "cannot list $root : $($_.Exception.Message)" }
|
|
239
|
+
|
|
240
|
+
$rootFiles = @()
|
|
241
|
+
try { $rootFiles = @(Get-ChildItem -LiteralPath $root -Force -File -ErrorAction Stop) }
|
|
242
|
+
catch { $result.partial = $true; $result.partialReason += "cannot list files in $root : $($_.Exception.Message)" }
|
|
243
|
+
|
|
244
|
+
# Lock files belong here too. Without them the "dependencies are pinned" check had no
|
|
245
|
+
# evidence it could ever find, so it reported a GAP for every npm project in the world
|
|
246
|
+
# - a check that cannot pass is as useless as one that cannot fail.
|
|
247
|
+
$manifestNames = @('pyproject.toml', 'requirements.txt', 'setup.py', 'setup.cfg', 'Pipfile',
|
|
248
|
+
'package.json', 'composer.json', 'pom.xml', 'build.gradle', 'build.gradle.kts', 'Gemfile',
|
|
249
|
+
'go.mod', 'Cargo.toml', 'Dockerfile', 'docker-compose.yml', 'docker-compose.yaml', 'Makefile',
|
|
250
|
+
'poetry.lock', 'Pipfile.lock', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
|
|
251
|
+
'composer.lock', 'Gemfile.lock', 'Cargo.lock', 'go.sum')
|
|
252
|
+
$entryNames = @('main.py', 'app.py', 'wsgi.py', 'asgi.py', 'manage.py', '__main__.py', 'server.js',
|
|
253
|
+
'index.js', 'server.ts', 'index.php', 'Program.cs', 'Application.java', 'Procfile', 'run.sh', 'start.sh')
|
|
254
|
+
$configGlobs = '(?i)^(\.env.*|.*\.ini|.*\.cfg|.*\.conf|.*\.toml|.*\.ya?ml|appsettings.*\.json|web\.config|settings\.json|config\.json)$'
|
|
255
|
+
$docNames = '(?i)^(readme(\..+)?|agents\.md|claude\.md|contributing\.md|changelog\.md)$'
|
|
256
|
+
$testMarkerNames = '(?i)^(pytest\.ini|tox\.ini|conftest\.py|jest\.config\.[cm]?[jt]s|vitest\.config\.[cm]?[jt]s|phpunit\.xml(\.dist)?|karma\.conf\.js|\.mocharc\..+)$'
|
|
257
|
+
|
|
258
|
+
$classify = {
|
|
259
|
+
param($file, $rel)
|
|
260
|
+
|
|
261
|
+
if ($manifestNames -contains $file.Name) { $result.manifests += @{ path = $rel; name = $file.Name } }
|
|
262
|
+
if ($entryNames -contains $file.Name) { $result.entryPoints += @{ path = $rel; why = "named $($file.Name)" } }
|
|
263
|
+
if ($file.Extension -match '(?i)^\.(ps1|bat|cmd|sh)$' -and $rel -notmatch '[\\/]') {
|
|
264
|
+
$result.entryPoints += @{ path = $rel; why = 'top-level script' }
|
|
265
|
+
}
|
|
266
|
+
if ($file.Name -match $testMarkerNames) { $result.testMarkers += $rel }
|
|
267
|
+
if ($file.Name -match $docNames) { $result.docFiles += $rel }
|
|
268
|
+
if ($rel -match '(?i)^\.(github|gitea|forgejo)[\\/]workflows[\\/]' -or
|
|
269
|
+
$file.Name -match '(?i)^(\.gitlab-ci\.yml|Jenkinsfile|azure-pipelines\.ya?ml|\.travis\.yml|appveyor\.yml|bitbucket-pipelines\.yml)$') {
|
|
270
|
+
$result.ciFiles += $rel
|
|
271
|
+
}
|
|
272
|
+
if ($rel -match '(?i)(^|[\\/])(tests?|spec|__tests__)[\\/]' -or $file.Name -match '(?i)(^test_.*\.py$|_test\.py$|\.test\.[jt]sx?$|\.spec\.[jt]sx?$|Test\.php$|Tests?\.cs$)') {
|
|
273
|
+
$result.testFiles++
|
|
274
|
+
}
|
|
275
|
+
if ($file.Name -match $configGlobs -and $file.Length -lt 512KB) { $result.configFiles += $rel }
|
|
276
|
+
$baseName = [System.IO.Path]::GetFileNameWithoutExtension($file.Name)
|
|
277
|
+
if ($rel -match $SecretNamePattern -and $baseName -notmatch $SecretNameExemptPattern -and $file.Name -notmatch $SecretNameExemptPattern) {
|
|
278
|
+
if ($file.Name -match $KeyContainerPattern) {
|
|
279
|
+
# A .pem is a container, not a verdict. A public certificate chain is not a
|
|
280
|
+
# finding; a private key is. Deciding by extension reported four public CA
|
|
281
|
+
# certificates as credentials, which is how a real one gets skimmed past.
|
|
282
|
+
[void]$keyContainers.Add($rel)
|
|
283
|
+
}
|
|
284
|
+
else { $result.secretFiles += @{ path = $rel; why = 'name looks like a credential file' } }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
# A shallow index of ordinary file paths, for profile detection. Without it a rule
|
|
288
|
+
# can only see manifests and config: a front end whose evidence is `index.html`
|
|
289
|
+
# matched nothing at all, and the component was silently missing from the plan.
|
|
290
|
+
# Shallow paths are ALWAYS kept. A single global cap is consumed by whichever tree
|
|
291
|
+
# the walk happens to reach first: on a real app it filled up before reaching the
|
|
292
|
+
# front end at all, so the front end was missing from the plan and nothing said so.
|
|
293
|
+
$depth = @($rel -split '[\\/]').Count
|
|
294
|
+
if ($depth -le 2) { [void]$detectPaths.Add($rel) }
|
|
295
|
+
elseif ($depth -le 4 -and $detectPaths.Count -lt $DetectPathCap) { [void]$detectPaths.Add($rel) }
|
|
296
|
+
|
|
297
|
+
$ext = "$($file.Extension)".ToLowerInvariant()
|
|
298
|
+
if (($textExtensions -contains $ext -or $file.Name -match '(?i)^(\.env.*|dockerfile|makefile|procfile)$') -and $file.Length -le $MaxProbeBytes) {
|
|
299
|
+
[void]$probeCandidates.Add($rel)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
foreach ($spec in $ExcludableSpecs) {
|
|
303
|
+
if ($rel -match $spec.pattern) {
|
|
304
|
+
$result.excludable += @{ path = $rel; category = $spec.category; bytes = $file.Length }
|
|
305
|
+
break
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
$walk = {
|
|
311
|
+
param($dir, $label)
|
|
312
|
+
|
|
313
|
+
$seen = 0
|
|
314
|
+
$bytes = [long]0
|
|
315
|
+
$truncated = $false
|
|
316
|
+
$denied = $false
|
|
317
|
+
$stack = New-Object System.Collections.Stack
|
|
318
|
+
$stack.Push($dir)
|
|
319
|
+
|
|
320
|
+
while ($stack.Count -gt 0) {
|
|
321
|
+
$cur = $stack.Pop()
|
|
322
|
+
$kids = $null
|
|
323
|
+
try { $kids = @(Get-ChildItem -LiteralPath $cur -Force -ErrorAction Stop) }
|
|
324
|
+
catch { $denied = $true; continue }
|
|
325
|
+
|
|
326
|
+
foreach ($k in $kids) {
|
|
327
|
+
# Never follow a reparse point. A junction inside a tree a tool manages is a
|
|
328
|
+
# live data-loss hazard, so record it and do not descend into it.
|
|
329
|
+
if ($k.Attributes -band [System.IO.FileAttributes]::ReparsePoint) {
|
|
330
|
+
$rp = $k.FullName
|
|
331
|
+
if ($rp.Length -gt $root.Length) { $rp = $rp.Substring($root.Length + 1) }
|
|
332
|
+
$result.reparse += @{ path = $rp; kind = "$($k.Attributes)" }
|
|
333
|
+
continue
|
|
334
|
+
}
|
|
335
|
+
if ($k.PSIsContainer) {
|
|
336
|
+
$stack.Push($k.FullName)
|
|
337
|
+
continue
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
$seen++
|
|
341
|
+
$bytes += $k.Length
|
|
342
|
+
$rel = $k.FullName
|
|
343
|
+
if ($rel.Length -gt $root.Length) { $rel = $rel.Substring($root.Length + 1) }
|
|
344
|
+
|
|
345
|
+
if ($rel -notmatch $SkipPattern) { & $classify $k $rel }
|
|
346
|
+
else {
|
|
347
|
+
foreach ($spec in $ExcludableSpecs) {
|
|
348
|
+
if ($rel -match $spec.pattern) { $result.excludable += @{ path = $rel; category = $spec.category; bytes = $k.Length }; break }
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if ($seen -ge $FileBudget) { $truncated = $true; break }
|
|
353
|
+
}
|
|
354
|
+
if ($truncated) { break }
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return @{ label = $label; files = $seen; bytes = $bytes; truncated = $truncated; denied = $denied }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
foreach ($f in $rootFiles) {
|
|
361
|
+
$result.totalFiles++
|
|
362
|
+
$result.totalMB += [math]::Round($f.Length / 1MB, 4)
|
|
363
|
+
& $classify $f $f.Name
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
foreach ($t in $tops) {
|
|
367
|
+
if ($t.Attributes -band [System.IO.FileAttributes]::ReparsePoint) {
|
|
368
|
+
$result.reparse += @{ path = $t.Name; kind = "$($t.Attributes)" }
|
|
369
|
+
continue
|
|
370
|
+
}
|
|
371
|
+
if ($SkipFolders -contains $t.Name) {
|
|
372
|
+
$result.folders += @{ path = $t.Name; files = 0; mb = 0; truncated = $false; denied = $false; skipped = $true; git = 'not-scanned' }
|
|
373
|
+
continue
|
|
374
|
+
}
|
|
375
|
+
$w = & $walk $t.FullName $t.Name
|
|
376
|
+
|
|
377
|
+
# Three states, and only git can tell them apart. The dangerous one is UNTRACKED:
|
|
378
|
+
# a folder that is neither committed nor ignored is one `git add -A` away from
|
|
379
|
+
# being in the repository for ever. A real app had 7.6 GB sitting in that state.
|
|
380
|
+
$gitState = 'no-repo'
|
|
381
|
+
if ($result.git.isRepo -and -not $gitAvailable) { $gitState = 'unknown' }
|
|
382
|
+
elseif ($result.git.isRepo) {
|
|
383
|
+
& git -c safe.directory=* -C $root check-ignore -q -- "$($t.Name)/" 2>&1 | Out-Null
|
|
384
|
+
if ($LASTEXITCODE -eq 0) { $gitState = 'ignored' }
|
|
385
|
+
else {
|
|
386
|
+
# Collect into an array, never `| Select-Object -First 1`. Select stops the
|
|
387
|
+
# pipeline early, which leaves $LASTEXITCODE at 1 even though git succeeded,
|
|
388
|
+
# so the exit-code test below failed for EVERY folder and a fully tracked
|
|
389
|
+
# repository was reported as entirely untracked. A wrong verdict that looks
|
|
390
|
+
# like a finding is worse than no verdict.
|
|
391
|
+
$tracked = @(& git -c safe.directory=* -C $root ls-files -- "$($t.Name)/" 2>&1)
|
|
392
|
+
if ($LASTEXITCODE -eq 0 -and $tracked.Count -gt 0 -and "$($tracked[0])" -ne '') { $gitState = 'tracked' }
|
|
393
|
+
else { $gitState = 'untracked' }
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
$entry = @{
|
|
398
|
+
path = $t.Name
|
|
399
|
+
files = $w.files
|
|
400
|
+
mb = [math]::Round($w.bytes / 1MB, 2)
|
|
401
|
+
truncated = $w.truncated
|
|
402
|
+
denied = $w.denied
|
|
403
|
+
skipped = $false
|
|
404
|
+
git = $gitState
|
|
405
|
+
}
|
|
406
|
+
$result.folders += $entry
|
|
407
|
+
$result.totalFiles += $w.files
|
|
408
|
+
$result.totalMB += $entry.mb
|
|
409
|
+
if ($w.truncated) {
|
|
410
|
+
$result.partial = $true
|
|
411
|
+
$result.partialReason += "$($t.Name): stopped after $($w.files) files (budget $FileBudget); its size is AT LEAST $($entry.mb) MB"
|
|
412
|
+
}
|
|
413
|
+
if ($w.denied) {
|
|
414
|
+
$result.partial = $true
|
|
415
|
+
$result.partialReason += "$($t.Name): access denied to at least one subfolder; its size is AT LEAST $($entry.mb) MB"
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
$result.totalMB = [math]::Round($result.totalMB, 2)
|
|
419
|
+
$result.paths = @($detectPaths)
|
|
420
|
+
if ($detectPaths.Count -ge $DetectPathCap) {
|
|
421
|
+
$result.partial = $true
|
|
422
|
+
$result.partialReason += "path index: reached the cap of $DetectPathCap shallow paths, so profile detection sees only part of the tree"
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
# ---- key containers: private key, or public certificate? ------------------------
|
|
426
|
+
foreach ($rel in @($keyContainers)) {
|
|
427
|
+
$full = Join-Path $root $rel
|
|
428
|
+
if (-not (Test-Path -LiteralPath $full)) { continue }
|
|
429
|
+
$fi = Get-Item -LiteralPath $full -Force -ErrorAction SilentlyContinue
|
|
430
|
+
if (-not $fi) { continue }
|
|
431
|
+
# An empty file holds nothing, and saying "unreadable - check it by hand" about a
|
|
432
|
+
# zero-byte placeholder sends someone to look at nothing.
|
|
433
|
+
if ($fi.Length -eq 0) { continue }
|
|
434
|
+
if ($fi.Length -gt $MaxProbeBytes) {
|
|
435
|
+
$result.secretFiles += @{ path = $rel; why = 'key container, too large to read - check it by hand' }
|
|
436
|
+
continue
|
|
437
|
+
}
|
|
438
|
+
$body = $null
|
|
439
|
+
try { $body = Get-Content -LiteralPath $full -Raw -ErrorAction Stop } catch { }
|
|
440
|
+
if ($null -eq $body) {
|
|
441
|
+
$result.secretFiles += @{ path = $rel; why = 'key container, unreadable - check it by hand' }
|
|
442
|
+
}
|
|
443
|
+
elseif ($body -match 'PRIVATE KEY') {
|
|
444
|
+
$result.secretFiles += @{ path = $rel; why = 'holds a PRIVATE KEY' }
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
$result.certificates += $rel
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
# ---- content probes -------------------------------------------------------------
|
|
452
|
+
$probe = @($probeCandidates | Sort-Object -Unique)
|
|
453
|
+
if ($probe.Count -gt $ProbeBudget) {
|
|
454
|
+
$result.partial = $true
|
|
455
|
+
$result.partialReason += "content probe: $($probe.Count) text files found, only the first $ProbeBudget were read for credentials and health signals"
|
|
456
|
+
$probe = @($probe | Select-Object -First $ProbeBudget)
|
|
457
|
+
}
|
|
458
|
+
$result.probed = $probe.Count
|
|
459
|
+
|
|
460
|
+
foreach ($rel in $probe) {
|
|
461
|
+
$full = Join-Path $root $rel
|
|
462
|
+
if (-not (Test-Path -LiteralPath $full)) { continue }
|
|
463
|
+
$fi = Get-Item -LiteralPath $full -Force -ErrorAction SilentlyContinue
|
|
464
|
+
if (-not $fi -or $fi.Length -gt $MaxProbeBytes) { continue }
|
|
465
|
+
$lines = $null
|
|
466
|
+
try { $lines = @(Get-Content -LiteralPath $full -ErrorAction Stop) } catch { continue }
|
|
467
|
+
if ($lines.Count -eq 0) { continue }
|
|
468
|
+
|
|
469
|
+
$found = $false
|
|
470
|
+
for ($i = 0; $i -lt $lines.Count -and -not $found; $i++) {
|
|
471
|
+
$line = "$($lines[$i])"
|
|
472
|
+
if ($line -match $CommentLinePattern) { continue }
|
|
473
|
+
foreach ($p in $SecretContentPatterns) {
|
|
474
|
+
$m = [regex]::Match($line, $p)
|
|
475
|
+
if (-not $m.Success) { continue }
|
|
476
|
+
$val = "$($m.Groups['val'].Value)".Trim().Trim('"', "'")
|
|
477
|
+
if ($val.Length -lt $MinSecretLength) { continue }
|
|
478
|
+
if ($val -match $NonLiteralValuePattern) { continue }
|
|
479
|
+
if ($val -match $ReferenceValuePattern) { continue }
|
|
480
|
+
# The opening of a template reference sits BEFORE the key, not inside the
|
|
481
|
+
# value: `{{SECRET:bff-user}}` yields the value `bff-user`, which passes
|
|
482
|
+
# every test above. Look back at the real text - with backslashes removed,
|
|
483
|
+
# because in a regex literal the same reference is written `\{\{SECRET:...`
|
|
484
|
+
# and a look-back that keeps the escaping sees `{\{` and misses it. That is
|
|
485
|
+
# exactly how 20 files of migration scripts were reported as credentials.
|
|
486
|
+
$lookBack = 0
|
|
487
|
+
if ($m.Index -gt 6) { $lookBack = $m.Index - 6 }
|
|
488
|
+
$before = $line.Substring($lookBack, $m.Index - $lookBack + 1).Replace('\', '')
|
|
489
|
+
if ($before -match '(\{\{|\$\{|%)') { continue }
|
|
490
|
+
# A value that ends in a backslash is part of a regular expression.
|
|
491
|
+
if ($val.EndsWith('\')) { continue }
|
|
492
|
+
# Three or more words is a sentence, not a credential. A quoted string of
|
|
493
|
+
# prose - "LocalMachine\My store, exportable=false policy observed" - is
|
|
494
|
+
# captured whole by the quoted alternative and passes every other test.
|
|
495
|
+
if (@($val -split '\s+' | Where-Object { $_ -ne '' }).Count -ge 3) { continue }
|
|
496
|
+
# The VALUE never leaves the machine in the report: only the file, the line
|
|
497
|
+
# and the key name. A report is a file, and a file gets copied around.
|
|
498
|
+
$keyName = ($m.Value -split '[:=]', 2)[0].Trim()
|
|
499
|
+
if ($keyName.Length -gt 60) { $keyName = $keyName.Substring(0, 60) }
|
|
500
|
+
$result.secretFiles += @{ path = $rel; why = 'value looks like a credential'; line = ($i + 1); key = $keyName }
|
|
501
|
+
$found = $true
|
|
502
|
+
break
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
foreach ($line in $lines) {
|
|
507
|
+
if ("$line" -match '(?i)(/health\b|/healthz\b|/api/health\b|healthcheck)') {
|
|
508
|
+
$result.healthHits += @{ path = $rel; signal = 'health route or check' }
|
|
509
|
+
break
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return $result
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
# --------------------------------------------------------------------------------------
|
|
518
|
+
function Get-AdoptConfig {
|
|
519
|
+
param([string]$RepoRoot = (Get-Location).Path)
|
|
520
|
+
|
|
521
|
+
$cfg = [ordered]@{}
|
|
522
|
+
foreach ($k in $script:AdoptDefaultConfig.Keys) { $cfg[$k] = $script:AdoptDefaultConfig[$k] }
|
|
523
|
+
|
|
524
|
+
$path = $env:ADOPT_APP_CONFIG
|
|
525
|
+
if (-not $path) { $path = Join-Path $RepoRoot '.caddis/adopt-app.json' }
|
|
526
|
+
if (Test-Path -LiteralPath $path) {
|
|
527
|
+
$json = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json
|
|
528
|
+
foreach ($p in $json.PSObject.Properties) { $cfg[$p.Name] = $p.Value }
|
|
529
|
+
$cfg['_configPath'] = (Resolve-Path -LiteralPath $path).ProviderPath
|
|
530
|
+
}
|
|
531
|
+
else { $cfg['_configPath'] = $null }
|
|
532
|
+
return $cfg
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function Resolve-AdoptOutDir {
|
|
536
|
+
param([Parameter(Mandatory)][string]$Name, [string]$OutDir, [string]$RepoRoot = (Get-Location).Path)
|
|
537
|
+
|
|
538
|
+
if (-not $OutDir) {
|
|
539
|
+
$cfg = Get-AdoptConfig -RepoRoot $RepoRoot
|
|
540
|
+
$OutDir = Join-Path $RepoRoot $cfg.outDir
|
|
541
|
+
}
|
|
542
|
+
$dir = Join-Path $OutDir $Name
|
|
543
|
+
if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
|
|
544
|
+
return (Resolve-Path -LiteralPath $dir).ProviderPath
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function Write-AdoptArtifact {
|
|
548
|
+
param([Parameter(Mandatory)][string]$Dir, [Parameter(Mandatory)][string]$FileName, [Parameter(Mandatory)]$Object)
|
|
549
|
+
$p = Join-Path $Dir $FileName
|
|
550
|
+
$Object | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $p -Encoding UTF8
|
|
551
|
+
return $p
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function Read-AdoptArtifact {
|
|
555
|
+
param([Parameter(Mandatory)][string]$Dir, [Parameter(Mandatory)][string]$FileName)
|
|
556
|
+
$p = Join-Path $Dir $FileName
|
|
557
|
+
if (-not (Test-Path -LiteralPath $p)) {
|
|
558
|
+
throw "No $FileName in $Dir. Run the earlier phase first (see the skill's phase table)."
|
|
559
|
+
}
|
|
560
|
+
return Get-Content -LiteralPath $p -Raw | ConvertFrom-Json
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function Invoke-AdoptScan {
|
|
564
|
+
param(
|
|
565
|
+
[Parameter(Mandatory)][string]$Path,
|
|
566
|
+
[string]$ComputerName,
|
|
567
|
+
[int]$FileBudget = 20000,
|
|
568
|
+
[string]$SkipPattern = $script:AdoptDefaultConfig.skipPattern,
|
|
569
|
+
[int]$MaxProbeBytes = 262144,
|
|
570
|
+
[int]$ProbeBudget = 4000,
|
|
571
|
+
[int]$DetectPathCap = 20000,
|
|
572
|
+
[string[]]$SkipFolders = @()
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
# NOT $args: that is PowerShell's automatic argument array, and writing to it inside a
|
|
576
|
+
# function is how a splat silently becomes the wrong list.
|
|
577
|
+
$scanArgs = @($Path, $FileBudget, $SkipPattern, $script:AdoptSecretNamePattern,
|
|
578
|
+
$script:AdoptSecretContentPatterns, $script:AdoptExcludablePatterns, $MaxProbeBytes,
|
|
579
|
+
$script:AdoptCommentLinePattern, $script:AdoptNonLiteralValuePattern, $ProbeBudget, $SkipFolders, $script:AdoptReferenceValuePattern, $script:AdoptMinSecretLength, $script:AdoptSecretNameExemptPattern, $script:AdoptKeyContainerPattern, $DetectPathCap)
|
|
580
|
+
|
|
581
|
+
if ($ComputerName) {
|
|
582
|
+
$session = $null
|
|
583
|
+
try {
|
|
584
|
+
$session = New-PSSession -ComputerName $ComputerName -ErrorAction Stop
|
|
585
|
+
return Invoke-Command -Session $session -ScriptBlock $script:AdoptScanBlock -ArgumentList $scanArgs
|
|
586
|
+
}
|
|
587
|
+
finally { if ($session) { Remove-PSSession -Session $session -ErrorAction SilentlyContinue } }
|
|
588
|
+
}
|
|
589
|
+
return & $script:AdoptScanBlock @scanArgs
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
# --------------------------------------------------------------------------------------
|
|
593
|
+
# Profiles. Same contract as the fleet copy this skill was extracted from, so a profile
|
|
594
|
+
# written for one is readable by the other.
|
|
595
|
+
# --------------------------------------------------------------------------------------
|
|
596
|
+
$script:AdoptProfileRequiredKeys = @('name', 'status', 'description', 'detect', 'runtimes', 'build', 'routing', 'config', 'data', 'ci', 'deploy')
|
|
597
|
+
|
|
598
|
+
function Get-AdoptProfileDir {
|
|
599
|
+
param([string]$ProfilesDir)
|
|
600
|
+
$p = $ProfilesDir
|
|
601
|
+
if (-not $p) { $p = Join-Path $PSScriptRoot '..\profiles' }
|
|
602
|
+
if (-not (Test-Path -LiteralPath $p)) {
|
|
603
|
+
throw "No profiles directory at '$p'. A profile describes one KIND of component; without any, nothing can be detected. Pass -ProfilesDir, or copy the skill's profiles/ folder next to scripts/."
|
|
604
|
+
}
|
|
605
|
+
return (Resolve-Path -LiteralPath $p).ProviderPath
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function Get-AdoptProfiles {
|
|
609
|
+
param([string]$ProfilesDir, [switch]$IncludeTemplate)
|
|
610
|
+
$dir = Get-AdoptProfileDir -ProfilesDir $ProfilesDir
|
|
611
|
+
$out = @()
|
|
612
|
+
foreach ($d in @(Get-ChildItem -LiteralPath $dir -Directory | Sort-Object Name)) {
|
|
613
|
+
if ($d.Name -eq '_template' -and -not $IncludeTemplate) { continue }
|
|
614
|
+
$f = Join-Path $d.FullName 'profile.json'
|
|
615
|
+
if (-not (Test-Path -LiteralPath $f)) { continue }
|
|
616
|
+
$p = Get-Content -LiteralPath $f -Raw | ConvertFrom-Json
|
|
617
|
+
$p | Add-Member -NotePropertyName _dir -NotePropertyValue $d.FullName -Force
|
|
618
|
+
$out += $p
|
|
619
|
+
}
|
|
620
|
+
return $out
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function Test-AdoptProfileContract {
|
|
624
|
+
param([Parameter(Mandatory)]$Spec)
|
|
625
|
+
$problems = @()
|
|
626
|
+
$names = @($Spec.PSObject.Properties.Name)
|
|
627
|
+
foreach ($k in $script:AdoptProfileRequiredKeys) { if ($names -notcontains $k) { $problems += "missing '$k'" } }
|
|
628
|
+
if ($names -notcontains 'services' -and $names -notcontains 'schedule') { $problems += "needs 'services' or 'schedule'" }
|
|
629
|
+
if ($names -contains 'status' -and $Spec.status -notin @('proven', 'draft', 'placeholder')) {
|
|
630
|
+
$problems += "status '$($Spec.status)' is not proven|draft|placeholder"
|
|
631
|
+
}
|
|
632
|
+
if ($names -contains 'status' -and $Spec.status -ne 'placeholder' -and "$($Spec.name)" -match '[<>]') {
|
|
633
|
+
$problems += "name still holds a placeholder"
|
|
634
|
+
}
|
|
635
|
+
return , $problems
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
# Matches one profile's detect rules against a FILE LIST (so it works for a remote scan
|
|
639
|
+
# without a second round trip). Returns 0 for no match, or a score: more matched rule
|
|
640
|
+
# kinds means a more specific match.
|
|
641
|
+
function Test-AdoptDetect {
|
|
642
|
+
param([Parameter(Mandatory)]$Detect, [Parameter(Mandatory)][string[]]$RelativePaths, [hashtable]$FileText = @{})
|
|
643
|
+
|
|
644
|
+
$has = {
|
|
645
|
+
param($glob)
|
|
646
|
+
$rx = '(?i)^' + [regex]::Escape($glob).Replace('\*\*', '.*').Replace('\*', '[^\\/]*').Replace('/', '[\\/]') + '$'
|
|
647
|
+
foreach ($p in $RelativePaths) { if ($p -match $rx) { return $true } }
|
|
648
|
+
return $false
|
|
649
|
+
}
|
|
650
|
+
$contains = {
|
|
651
|
+
param($spec)
|
|
652
|
+
foreach ($g in @($spec.file)) {
|
|
653
|
+
$rx = '(?i)^' + [regex]::Escape($g).Replace('\*\*', '.*').Replace('\*', '[^\\/]*').Replace('/', '[\\/]') + '$'
|
|
654
|
+
foreach ($k in $FileText.Keys) {
|
|
655
|
+
if ($k -match $rx -and "$($FileText[$k])" -match $spec.pattern) { return $true }
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
return $false
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
$names = @($Detect.PSObject.Properties.Name)
|
|
662
|
+
$score = 0
|
|
663
|
+
if ($names -contains 'allFiles') { foreach ($f in @($Detect.allFiles)) { if (-not (& $has $f)) { return 0 } }; $score++ }
|
|
664
|
+
if ($names -contains 'anyFiles') { if (-not (@($Detect.anyFiles) | Where-Object { & $has $_ })) { return 0 }; $score++ }
|
|
665
|
+
if ($names -contains 'noneFiles') { if (@($Detect.noneFiles) | Where-Object { & $has $_ }) { return 0 } }
|
|
666
|
+
if ($names -contains 'fileContains') { foreach ($c in @($Detect.fileContains)) { if (-not (& $contains $c)) { return 0 } }; $score++ }
|
|
667
|
+
if ($names -contains 'noneContain') { foreach ($c in @($Detect.noneContain)) { if (& $contains $c) { return 0 } } }
|
|
668
|
+
return $score
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
# Fetches the text of a few small files, local or remote, for profile detection.
|
|
672
|
+
#
|
|
673
|
+
# File bodies are NEVER written into an artefact by this skill. A config file holds
|
|
674
|
+
# credentials, and an artefact is a file that gets copied into a ticket, a chat and a
|
|
675
|
+
# report. Detection reads the text in memory and keeps only the verdict.
|
|
676
|
+
function Get-AdoptFileText {
|
|
677
|
+
param(
|
|
678
|
+
[Parameter(Mandatory)][string]$Path,
|
|
679
|
+
[string]$ComputerName,
|
|
680
|
+
[Parameter(Mandatory)][string[]]$RelativePaths,
|
|
681
|
+
[int]$MaxBytes = 262144
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
$block = {
|
|
685
|
+
param($root, $rels, $maxBytes)
|
|
686
|
+
$out = @{}
|
|
687
|
+
foreach ($rel in $rels) {
|
|
688
|
+
$full = Join-Path $root $rel
|
|
689
|
+
if (-not (Test-Path -LiteralPath $full)) { continue }
|
|
690
|
+
$fi = Get-Item -LiteralPath $full -Force -ErrorAction SilentlyContinue
|
|
691
|
+
if (-not $fi -or $fi.Length -gt $maxBytes) { continue }
|
|
692
|
+
try { $out[$rel] = Get-Content -LiteralPath $full -Raw -ErrorAction Stop } catch { }
|
|
693
|
+
}
|
|
694
|
+
return $out
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if ($ComputerName) {
|
|
698
|
+
$session = $null
|
|
699
|
+
try {
|
|
700
|
+
$session = New-PSSession -ComputerName $ComputerName -ErrorAction Stop
|
|
701
|
+
return Invoke-Command -Session $session -ScriptBlock $block -ArgumentList @($Path, $RelativePaths, $MaxBytes)
|
|
702
|
+
}
|
|
703
|
+
finally { if ($session) { Remove-PSSession -Session $session -ErrorAction SilentlyContinue } }
|
|
704
|
+
}
|
|
705
|
+
return & $block $Path $RelativePaths $MaxBytes
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
# What is registered on a host to RUN this folder. An app nobody bootstrapped is usually
|
|
709
|
+
# started by something nobody wrote down: a service, a scheduled task, a web site.
|
|
710
|
+
function Get-AdoptRunRegistrations {
|
|
711
|
+
param([Parameter(Mandatory)][string]$Path, [string]$ComputerName)
|
|
712
|
+
|
|
713
|
+
$block = {
|
|
714
|
+
param($needle)
|
|
715
|
+
$out = @{ services = @(); scheduledTasks = @(); webSites = @(); checked = @(); errors = @() }
|
|
716
|
+
|
|
717
|
+
try {
|
|
718
|
+
$svcs = Get-CimInstance Win32_Service -ErrorAction Stop |
|
|
719
|
+
Where-Object { "$($_.PathName)" -like "*$needle*" }
|
|
720
|
+
foreach ($s in $svcs) {
|
|
721
|
+
$out.services += @{ name = $s.Name; state = $s.State; startMode = $s.StartMode; account = $s.StartName; path = $s.PathName }
|
|
722
|
+
}
|
|
723
|
+
$out.checked += 'services'
|
|
724
|
+
}
|
|
725
|
+
catch { $out.errors += "services: $($_.Exception.Message)" }
|
|
726
|
+
|
|
727
|
+
try {
|
|
728
|
+
$tasks = Get-ScheduledTask -ErrorAction Stop
|
|
729
|
+
foreach ($t in $tasks) {
|
|
730
|
+
# Not every action is an Exec action. A ComHandler or SendEmail action has
|
|
731
|
+
# no Execute property at all, and reading it under StrictMode throws - which
|
|
732
|
+
# killed the whole scheduled-task check on a box that had one.
|
|
733
|
+
$hits = @($t.Actions | Where-Object {
|
|
734
|
+
$props = @($_.PSObject.Properties.Name)
|
|
735
|
+
$exec = ''; $argus = ''; $wd = ''
|
|
736
|
+
if ($props -contains 'Execute') { $exec = "$($_.Execute)" }
|
|
737
|
+
if ($props -contains 'Arguments') { $argus = "$($_.Arguments)" }
|
|
738
|
+
if ($props -contains 'WorkingDirectory') { $wd = "$($_.WorkingDirectory)" }
|
|
739
|
+
$exec -like "*$needle*" -or $argus -like "*$needle*" -or $wd -like "*$needle*"
|
|
740
|
+
})
|
|
741
|
+
if ($hits.Count -eq 0) { continue }
|
|
742
|
+
$info = $null
|
|
743
|
+
try { $info = $t | Get-ScheduledTaskInfo -ErrorAction Stop } catch { }
|
|
744
|
+
$lastRun = $null
|
|
745
|
+
if ($info) { $lastRun = "$($info.LastRunTime)" }
|
|
746
|
+
$out.scheduledTasks += @{
|
|
747
|
+
name = $t.TaskName
|
|
748
|
+
path = $t.TaskPath
|
|
749
|
+
state = "$($t.State)"
|
|
750
|
+
account = "$($t.Principal.UserId)"
|
|
751
|
+
action = ("$($hits[0].Execute) $($hits[0].Arguments)").Trim()
|
|
752
|
+
lastRun = $lastRun
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
$out.checked += 'scheduledTasks'
|
|
756
|
+
}
|
|
757
|
+
catch { $out.errors += "scheduledTasks: $($_.Exception.Message)" }
|
|
758
|
+
|
|
759
|
+
try {
|
|
760
|
+
Import-Module WebAdministration -ErrorAction Stop
|
|
761
|
+
foreach ($s in @(Get-Website)) {
|
|
762
|
+
if ("$($s.physicalPath)" -like "*$needle*") {
|
|
763
|
+
$out.webSites += @{ name = $s.Name; state = "$($s.State)"; physicalPath = "$($s.physicalPath)" }
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
foreach ($a in @(Get-WebApplication)) {
|
|
767
|
+
if ("$($a.PhysicalPath)" -like "*$needle*") {
|
|
768
|
+
$out.webSites += @{ name = "$($a.Path)"; state = 'application'; physicalPath = "$($a.PhysicalPath)" }
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
$out.checked += 'webSites'
|
|
772
|
+
}
|
|
773
|
+
catch { $out.errors += "webSites: $($_.Exception.Message)" }
|
|
774
|
+
|
|
775
|
+
return $out
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
# Match on the last path segment: a service command line names a local drive path, and
|
|
779
|
+
# the caller may have handed us a UNC path to the same folder.
|
|
780
|
+
$needle = Split-Path $Path -Leaf
|
|
781
|
+
|
|
782
|
+
if ($ComputerName) {
|
|
783
|
+
$session = $null
|
|
784
|
+
try {
|
|
785
|
+
$session = New-PSSession -ComputerName $ComputerName -ErrorAction Stop
|
|
786
|
+
return Invoke-Command -Session $session -ScriptBlock $block -ArgumentList @($needle)
|
|
787
|
+
}
|
|
788
|
+
finally { if ($session) { Remove-PSSession -Session $session -ErrorAction SilentlyContinue } }
|
|
789
|
+
}
|
|
790
|
+
return & $block $needle
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
# Reads a key that may not be there. Under StrictMode a missing property throws, and these
|
|
794
|
+
# records are deliberately heterogeneous: a credential found by NAME has no line number, a
|
|
795
|
+
# credential found by VALUE does. Reading it directly killed two scripts mid-report.
|
|
796
|
+
function Get-AdoptProp {
|
|
797
|
+
param($Object, [Parameter(Mandatory)][string]$Name, $Default = $null)
|
|
798
|
+
if ($null -eq $Object) { return $Default }
|
|
799
|
+
if ($Object -is [hashtable]) {
|
|
800
|
+
if ($Object.ContainsKey($Name)) { return $Object[$Name] }
|
|
801
|
+
return $Default
|
|
802
|
+
}
|
|
803
|
+
if (@($Object.PSObject.Properties.Name) -contains $Name) { return $Object.$Name }
|
|
804
|
+
return $Default
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function Write-AdoptHeading {
|
|
808
|
+
param([Parameter(Mandatory)][string]$Text)
|
|
809
|
+
Write-Host ""
|
|
810
|
+
Write-Host $Text -ForegroundColor Cyan
|
|
811
|
+
Write-Host ('-' * $Text.Length) -ForegroundColor DarkCyan
|
|
812
|
+
}
|