taskuary 0.2.0__py3-none-any.whl
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.
- taskuary/__init__.py +2 -0
- taskuary/agents.py +218 -0
- taskuary/artifacts.py +209 -0
- taskuary/aws.py +226 -0
- taskuary/azure.py +334 -0
- taskuary/blackboard.py +150 -0
- taskuary/channels.py +768 -0
- taskuary/ci.py +237 -0
- taskuary/cli.py +47 -0
- taskuary/coder.py +132 -0
- taskuary/config.py +62 -0
- taskuary/db.py +63 -0
- taskuary/desktop.py +72 -0
- taskuary/devtools.py +352 -0
- taskuary/digest.py +104 -0
- taskuary/docsync.py +124 -0
- taskuary/github.py +136 -0
- taskuary/histgen.py +220 -0
- taskuary/imapmail.py +150 -0
- taskuary/ingest.py +344 -0
- taskuary/learn.py +181 -0
- taskuary/llm.py +175 -0
- taskuary/logs.py +17 -0
- taskuary/mcp.py +78 -0
- taskuary/messengers.py +205 -0
- taskuary/mssql.py +64 -0
- taskuary/outbound.py +221 -0
- taskuary/phone.py +88 -0
- taskuary/pm.py +310 -0
- taskuary/policy.py +59 -0
- taskuary/proof.py +194 -0
- taskuary/proposals.py +132 -0
- taskuary/reports.py +508 -0
- taskuary/reshape.py +201 -0
- taskuary/responder.py +199 -0
- taskuary/routing.py +115 -0
- taskuary/scopes.py +88 -0
- taskuary/server.py +1506 -0
- taskuary/store.py +711 -0
- taskuary/templates/coder.md +35 -0
- taskuary/templates/digest.md +5 -0
- taskuary/templates/learned.md +31 -0
- taskuary/templates/soul.md +44 -0
- taskuary/templates/style.md +14 -0
- taskuary/templates/triage.md +22 -0
- taskuary/terminal.py +971 -0
- taskuary/toil.py +57 -0
- taskuary/triage.py +123 -0
- taskuary/verdicts.py +73 -0
- taskuary/web/assets/index-6GBZ9nXN.css +32 -0
- taskuary/web/assets/index-Cjj87C2X.js +401 -0
- taskuary/web/favicon.ico +0 -0
- taskuary/web/favicon.png +0 -0
- taskuary/web/index.html +25 -0
- taskuary/whatsapp/bridge.mjs +101 -0
- taskuary/whatsapp/package.json +11 -0
- taskuary-0.2.0.dist-info/METADATA +424 -0
- taskuary-0.2.0.dist-info/RECORD +62 -0
- taskuary-0.2.0.dist-info/WHEEL +5 -0
- taskuary-0.2.0.dist-info/entry_points.txt +3 -0
- taskuary-0.2.0.dist-info/licenses/LICENSE +21 -0
- taskuary-0.2.0.dist-info/top_level.txt +1 -0
taskuary/aws.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""AWS connector - boto3 with keys from the card (the write-only secret IS the secret
|
|
2
|
+
access key); leave everything blank and boto3's default chain takes over (env vars,
|
|
3
|
+
~/.aws/credentials, an instance role). Three report/tool types: 'aws' calls ANY service
|
|
4
|
+
operation, 's3_object' reads or lists a bucket, 'cloudwatch_logs' greps a log group.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _boto3():
|
|
12
|
+
try: import boto3
|
|
13
|
+
except ImportError:
|
|
14
|
+
# `pip install taskuary[aws]` is a TRAP on an install whose metadata predates the
|
|
15
|
+
# extra (an editable install pins its dist-info at install time): pip prints
|
|
16
|
+
# "does not provide the extra 'aws'", says everything is already satisfied, and
|
|
17
|
+
# installs nothing. Naming the package itself always works.
|
|
18
|
+
raise RuntimeError('boto3 is not installed - run: pip install boto3 '
|
|
19
|
+
'(or re-run pip install -e .[aws] from the repo)')
|
|
20
|
+
return boto3
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# One card, many regions. CloudWatch log groups exist PER REGION - the same account shows a
|
|
24
|
+
# completely different set in us-east-1 and us-east-2 - so a single region field meant half an
|
|
25
|
+
# account was invisible with no sign that anything was missing. The field takes a list now
|
|
26
|
+
# ("us-east-2, us-east-1"); one value still behaves exactly as it did.
|
|
27
|
+
def regions(cfg: dict) -> list:
|
|
28
|
+
"""The regions to sweep, in order. [None] = no region set, so boto3's own default chain
|
|
29
|
+
decides (env, ~/.aws/config, an instance role) - which is what a blank field always meant."""
|
|
30
|
+
out = [r.strip() for r in str(cfg.get('region') or '').replace(';', ',').replace(' ', ',').split(',') if r.strip()]
|
|
31
|
+
return list(dict.fromkeys(out)) or [None]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _key(addr: str, region):
|
|
35
|
+
"""What makes this object itself. A bucket is global - its name IS its identity, and the
|
|
36
|
+
region only says which endpoint to read it from. A log group is regional, so its name means
|
|
37
|
+
nothing without one."""
|
|
38
|
+
return addr if addr.startswith('s3://') else (addr, region)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def client(cfg: dict, service: str, region: str = None):
|
|
42
|
+
"""`region` overrides the card - what a per-object call uses, because a discovered object
|
|
43
|
+
knows which region it was found in and the card's FIRST region is only a default."""
|
|
44
|
+
kw = {k: v for k, v in {'region_name': region or regions(cfg)[0],
|
|
45
|
+
'aws_access_key_id': cfg.get('access_key_id'),
|
|
46
|
+
'aws_secret_access_key': cfg.get('secret_access_key')}.items() if v}
|
|
47
|
+
return _boto3().client(service, **kw)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test(cfg: dict) -> dict:
|
|
51
|
+
"""STS caller identity - proves the keys (or the default chain) actually authenticate."""
|
|
52
|
+
try:
|
|
53
|
+
who = client(cfg, 'sts').get_caller_identity()
|
|
54
|
+
return {'ok': True, 'detail': f"authenticated · account {who['Account']} · {who['Arn']}"}
|
|
55
|
+
except Exception as e:
|
|
56
|
+
return {'ok': False, 'error': str(e)[:500]}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def discover(store, cfg: dict, connector_id: int, actor: str = 'owner') -> dict:
|
|
60
|
+
"""What can these keys SEE? Every S3 bucket and CloudWatch log group is registered as
|
|
61
|
+
a source with its own mode picker - report (default: selectable on the Reports tab,
|
|
62
|
+
nothing polled), feed (new items shown on the Timeline), tasks (through triage), or
|
|
63
|
+
off. A list that partially fails still registers what it found."""
|
|
64
|
+
# WHAT MAKES AN OBJECT ITSELF differs by service, and getting this wrong loses rows either
|
|
65
|
+
# way. A log group is (name, region): /aws/lambda/ingest in us-east-1 and the one in
|
|
66
|
+
# us-east-2 are unrelated groups that happen to share a name, and keying on the name alone
|
|
67
|
+
# registers whichever came first and drops the other. A BUCKET is its name and nothing
|
|
68
|
+
# else - S3 is one global namespace - so keying a bucket on (name, region) would file the
|
|
69
|
+
# same bucket twice the moment get_bucket_location answered differently than last time
|
|
70
|
+
# (a permission removed, a fallback taken), splitting its mode across two rows.
|
|
71
|
+
known = {}
|
|
72
|
+
for row in store.list_sources(active_only=False):
|
|
73
|
+
if row['Channel'] != 'aws': continue
|
|
74
|
+
known[_key(row['Address'], json.loads(row.get('ConfigJson') or '{}').get('region'))] = row
|
|
75
|
+
regs = regions(cfg)
|
|
76
|
+
found = [] # [(address, region)]
|
|
77
|
+
# S3 is one global namespace - listing it once per region would return the same buckets
|
|
78
|
+
# every time. Each bucket's OWN region is asked for separately, because a client pointed at
|
|
79
|
+
# the wrong one gets a 301 the moment it tries to read the objects.
|
|
80
|
+
try:
|
|
81
|
+
for b in client(cfg, 's3').list_buckets().get('Buckets') or []:
|
|
82
|
+
try:
|
|
83
|
+
loc = client(cfg, 's3').get_bucket_location(Bucket=b['Name']).get('LocationConstraint')
|
|
84
|
+
where = loc or 'us-east-1' # the API answers None for us-east-1, historically
|
|
85
|
+
except Exception:
|
|
86
|
+
where = regs[0] # no permission to ask: the card's first region
|
|
87
|
+
found.append((f"s3://{b['Name']}", where))
|
|
88
|
+
except Exception as e:
|
|
89
|
+
logger.warning(f'aws discovery: s3 list failed: {e}')
|
|
90
|
+
for reg in regs:
|
|
91
|
+
try:
|
|
92
|
+
# describe_log_groups caps at 50 PER CALL, so a single call silently truncates an
|
|
93
|
+
# account with hundreds of lambdas - page until the token runs out (bounded).
|
|
94
|
+
logs, tok, pages = client(cfg, 'logs', reg), None, 0
|
|
95
|
+
while pages < 8:
|
|
96
|
+
r = logs.describe_log_groups(limit=50, **({'nextToken': tok} if tok else {}))
|
|
97
|
+
found += [(f"logs://{g['logGroupName']}", reg) for g in r.get('logGroups') or []]
|
|
98
|
+
tok, pages = r.get('nextToken'), pages + 1
|
|
99
|
+
if not tok: break
|
|
100
|
+
except Exception as e:
|
|
101
|
+
# one bad region must not cost the others: a key without permission in eu-west-1 is
|
|
102
|
+
# a reason to skip eu-west-1, not to discover nothing
|
|
103
|
+
logger.warning(f'aws discovery: log groups failed in {reg or "the default region"}: {e}')
|
|
104
|
+
added, stamped = 0, 0
|
|
105
|
+
for addr, reg in found:
|
|
106
|
+
# a row discovered before the card knew about regions carries none. It is the SAME
|
|
107
|
+
# object, so it is adopted and stamped - not replaced by a second copy that would take
|
|
108
|
+
# the polling while the owner's mode pick sat on the row above it.
|
|
109
|
+
row = known.get(_key(addr, reg)) or known.get(_key(addr, None))
|
|
110
|
+
if row:
|
|
111
|
+
scfg = json.loads(row.get('ConfigJson') or '{}')
|
|
112
|
+
if not scfg.get('region') and reg:
|
|
113
|
+
store.save_source({'SourceId': row['SourceId'],
|
|
114
|
+
'ConfigJson': json.dumps({**scfg, 'region': reg})}, actor)
|
|
115
|
+
known.pop(_key(addr, None), None)
|
|
116
|
+
known[_key(addr, reg)] = {**row, 'ConfigJson': json.dumps({**scfg, 'region': reg})}
|
|
117
|
+
stamped += 1
|
|
118
|
+
continue
|
|
119
|
+
store.save_source({'Channel': 'aws', 'Address': addr, 'ConnectorId': connector_id, 'Active': 1,
|
|
120
|
+
'Owner': 'discovered', 'ConfigJson': json.dumps({'mode': 'report', 'region': reg})}, actor)
|
|
121
|
+
known[_key(addr, reg)] = {'SourceId': None, 'Address': addr,
|
|
122
|
+
'ConfigJson': json.dumps({'mode': 'report', 'region': reg})}
|
|
123
|
+
added += 1
|
|
124
|
+
out = {'found': len(found), 'added': added, 'stamped': stamped, 'regions': [r for r in regs if r]}
|
|
125
|
+
if not found:
|
|
126
|
+
out['hint'] = ('the keys authenticate but list nothing - they need s3:ListAllMyBuckets and '
|
|
127
|
+
'logs:DescribeLogGroups (AmazonS3ReadOnlyAccess + CloudWatchLogsReadOnlyAccess '
|
|
128
|
+
'cover both). The log above says which call failed.')
|
|
129
|
+
return out
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def poll_source(store, cfg: dict, src: dict, since, llm=None, file_only=False) -> int:
|
|
133
|
+
"""One discovered object in tasks/feed mode. s3://bucket -> a Timeline item per NEW
|
|
134
|
+
object; logs://group -> ONE batched item of the new matching events (default pattern:
|
|
135
|
+
errors - one row per log line would flood the funnel it feeds)."""
|
|
136
|
+
from .ingest import ingest_message
|
|
137
|
+
scfg = json.loads(src.get('ConfigJson') or '{}')
|
|
138
|
+
# the object's OWN region, stamped on it at discovery. Sources found before the card took
|
|
139
|
+
# a list have none, and the card's first region is exactly what they were found with.
|
|
140
|
+
reg = scfg.get('region')
|
|
141
|
+
addr, floor, n = src['Address'], since.strftime('%Y-%m-%d %H:%M:%S'), 0
|
|
142
|
+
if addr.startswith('s3://'):
|
|
143
|
+
bucket = addr[5:]
|
|
144
|
+
r = client(cfg, 's3', reg).list_objects_v2(Bucket=bucket, MaxKeys=200)
|
|
145
|
+
for o in r.get('Contents') or []:
|
|
146
|
+
at = o.get('LastModified')
|
|
147
|
+
stamp = at.astimezone().strftime('%Y-%m-%d %H:%M:%S') if hasattr(at, 'astimezone') else str(at)
|
|
148
|
+
if stamp < floor: continue
|
|
149
|
+
out = ingest_message(store, file_only=file_only, msg={
|
|
150
|
+
'external_id': f"aws:{reg or '-'}:{bucket}:{o['Key']}:{stamp[:16]}", 'channel': 'aws',
|
|
151
|
+
'subject': f"New in {addr}: {o['Key']}" + (f' ({reg})' if reg else ''),
|
|
152
|
+
'body': f"[S3 object landed - {o.get('Size')} bytes]\ns3://{bucket}/{o['Key']}",
|
|
153
|
+
'from_name': addr, 'conversation_id': f'aws:{addr}', 'sent_at': stamp,
|
|
154
|
+
'source_name': addr}, llm=llm)
|
|
155
|
+
n += out['status'] != 'duplicate'
|
|
156
|
+
elif addr.startswith('logs://'):
|
|
157
|
+
group = addr[7:]
|
|
158
|
+
pat = scfg.get('pattern') or '?ERROR ?Exception ?FATAL'
|
|
159
|
+
ev = client(cfg, 'logs', reg).filter_log_events(logGroupName=group, filterPattern=pat, limit=100,
|
|
160
|
+
startTime=int(since.timestamp() * 1000)).get('events') or []
|
|
161
|
+
if ev:
|
|
162
|
+
lines = '\n'.join(f"{datetime.fromtimestamp(e['timestamp'] / 1000).strftime('%H:%M:%S')} "
|
|
163
|
+
f"{(e.get('message') or '').strip()[:300]}" for e in ev[:50])
|
|
164
|
+
stamp = datetime.fromtimestamp(ev[-1]['timestamp'] / 1000).strftime('%Y-%m-%d %H:%M:%S')
|
|
165
|
+
out = ingest_message(store, file_only=file_only, msg={
|
|
166
|
+
'external_id': f"awslogs:{reg or '-'}:{group}:{ev[-1].get('eventId') or stamp}", 'channel': 'aws',
|
|
167
|
+
'subject': f"{len(ev)} matching log events in {group}" + (f' ({reg})' if reg else ''),
|
|
168
|
+
'body': f"[CloudWatch {group} - pattern {pat}]\n{lines}",
|
|
169
|
+
'from_name': addr, 'conversation_id': f'aws:{addr}', 'sent_at': stamp,
|
|
170
|
+
'source_name': addr}, llm=llm)
|
|
171
|
+
n += out['status'] != 'duplicate'
|
|
172
|
+
return n
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def dot_path(data, path):
|
|
176
|
+
for k in (path or '').split('.'):
|
|
177
|
+
if k: data = data[int(k)] if isinstance(data, list) else (data or {}).get(k)
|
|
178
|
+
return data
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def run_aws(cfg: dict):
|
|
182
|
+
"""{"service", "operation", "params": {...}, "path": "a.b"} - any boto3 call: service=s3
|
|
183
|
+
operation=list_buckets, service=logs operation=describe_log_groups, service=athena...
|
|
184
|
+
A list at `path` (or the first list in the response) comes back row-capped and honest."""
|
|
185
|
+
from .reports import row_limit, rows_out, BODY_CHARS
|
|
186
|
+
out = getattr(client(cfg, cfg['service']), cfg['operation'])(**(cfg.get('params') or {}))
|
|
187
|
+
if isinstance(out, dict): out.pop('ResponseMetadata', None)
|
|
188
|
+
data = dot_path(out, cfg.get('path')) if cfg.get('path') else out
|
|
189
|
+
if isinstance(data, dict) and not cfg.get('path'):
|
|
190
|
+
lists = [v for v in data.values() if isinstance(v, list)]
|
|
191
|
+
if len(lists) == 1 and len(data) == 1: data = lists[0] # the one-key list envelope
|
|
192
|
+
if isinstance(data, list):
|
|
193
|
+
lim, mine = row_limit(cfg)
|
|
194
|
+
return rows_out(data, lim, unit='items', mine=mine)
|
|
195
|
+
return 'ok', json.dumps(data, indent=1, default=str)[:BODY_CHARS]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def run_s3_object(cfg: dict):
|
|
199
|
+
"""{"bucket", "key"} fetches the object itself (text head); {"bucket", "prefix"} lists
|
|
200
|
+
what's under it (key, size, modified)."""
|
|
201
|
+
from .reports import row_limit, rows_out, BODY_CHARS
|
|
202
|
+
s3 = client(cfg, 's3')
|
|
203
|
+
if cfg.get('key'):
|
|
204
|
+
o = s3.get_object(Bucket=cfg['bucket'], Key=cfg['key'])
|
|
205
|
+
raw = o['Body'].read(BODY_CHARS + 1)
|
|
206
|
+
size = int(o.get('ContentLength') or len(raw))
|
|
207
|
+
head = f"s3://{cfg['bucket']}/{cfg['key']} · {size} bytes" + (' (shown truncated)' if len(raw) > BODY_CHARS else '')
|
|
208
|
+
return head, raw.decode('utf-8', errors='replace')[:BODY_CHARS]
|
|
209
|
+
lim, mine = row_limit(cfg)
|
|
210
|
+
r = s3.list_objects_v2(Bucket=cfg['bucket'], Prefix=cfg.get('prefix') or '', MaxKeys=min(lim + 1, 1000))
|
|
211
|
+
rows = [{'key': o['Key'], 'size': o['Size'], 'modified': str(o['LastModified'])} for o in r.get('Contents') or []]
|
|
212
|
+
return rows_out(rows, lim, unit='objects', mine=mine)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def run_cloudwatch_logs(cfg: dict):
|
|
216
|
+
"""{"log_group", "pattern", "hours": 24} - recent events from a CloudWatch log group.
|
|
217
|
+
Pattern uses CloudWatch filter syntax: '?ERROR ?Exception' means any of these words."""
|
|
218
|
+
from .reports import row_limit, rows_out
|
|
219
|
+
lim, mine = row_limit(cfg)
|
|
220
|
+
start = int((datetime.now() - timedelta(hours=float(cfg.get('hours') or 24))).timestamp() * 1000)
|
|
221
|
+
kw = {'logGroupName': cfg['log_group'], 'startTime': start, 'limit': min(lim + 1, 10000)}
|
|
222
|
+
if cfg.get('pattern'): kw['filterPattern'] = cfg['pattern']
|
|
223
|
+
ev = client(cfg, 'logs').filter_log_events(**kw).get('events') or []
|
|
224
|
+
rows = [{'at': datetime.fromtimestamp(e['timestamp'] / 1000).strftime('%Y-%m-%d %H:%M:%S'),
|
|
225
|
+
'stream': e.get('logStreamName'), 'message': (e.get('message') or '').strip()[:500]} for e in ev]
|
|
226
|
+
return rows_out(rows, lim, unit='events', mine=mine)
|
taskuary/azure.py
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""Azure connector - client-credentials tokens over plain requests, the same app-registration
|
|
2
|
+
road Outlook/Teams already ride (leave the card blank and it borrows the Outlook app; one
|
|
3
|
+
registration can hold Graph permissions AND Azure RBAC roles). Three report/tool types:
|
|
4
|
+
'azure' GETs ANY Azure Resource Manager path, 'azure_blob' reads or lists a storage
|
|
5
|
+
container, 'azure_logs' runs KQL against a Log Analytics workspace. The app needs a role
|
|
6
|
+
on the target: Reader for ARM, Storage Blob Data Reader, Log Analytics Reader.
|
|
7
|
+
"""
|
|
8
|
+
import json, os, re, requests
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from loguru import logger
|
|
11
|
+
|
|
12
|
+
ARM = 'https://management.azure.com'
|
|
13
|
+
STORAGE_VER = '2021-08-06'
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def token(cfg: dict, scope: str) -> str:
|
|
17
|
+
tid = cfg.get('tenant_id') or os.getenv('AZURE_TENANT_ID')
|
|
18
|
+
cid = cfg.get('client_id') or os.getenv('AZURE_CLIENT_ID')
|
|
19
|
+
sec = cfg.get('client_secret') or os.getenv('AZURE_CLIENT_SECRET')
|
|
20
|
+
if not (tid and cid and sec):
|
|
21
|
+
raise RuntimeError('need tenant_id + client_id + a client secret - set them on the Azure card, '
|
|
22
|
+
'or set up the Outlook connector (its app is borrowed automatically)')
|
|
23
|
+
r = requests.post(f'https://login.microsoftonline.com/{tid}/oauth2/v2.0/token', timeout=20,
|
|
24
|
+
data={'client_id': cid, 'client_secret': sec,
|
|
25
|
+
'grant_type': 'client_credentials', 'scope': scope})
|
|
26
|
+
if r.status_code != 200: raise RuntimeError(f'token failed ({r.status_code}): {r.text[:300]}')
|
|
27
|
+
return r.json()['access_token']
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get(url, tok, **kw):
|
|
31
|
+
r = requests.get(url, headers={'Authorization': f'Bearer {tok}', 'x-ms-version': STORAGE_VER},
|
|
32
|
+
timeout=60, **kw)
|
|
33
|
+
if r.status_code >= 400: raise RuntimeError(f'{r.status_code}: {r.text[:300]}')
|
|
34
|
+
return r
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test(cfg: dict) -> dict:
|
|
38
|
+
"""Mint a management token, then list what the app can actually SEE - a token with no
|
|
39
|
+
role assignments is a setup half-done, and a bare green would hide that."""
|
|
40
|
+
try:
|
|
41
|
+
tok = token(cfg, f'{ARM}/.default')
|
|
42
|
+
subs = _get(f'{ARM}/subscriptions', tok, params={'api-version': '2022-12-01'}).json().get('value') or []
|
|
43
|
+
if not subs:
|
|
44
|
+
return {'ok': True, 'detail': 'token OK, but the app sees no subscriptions - grant it a role '
|
|
45
|
+
'(e.g. Reader) on a subscription or resource group for ARM reads. '
|
|
46
|
+
'Blob and Log Analytics reads use their own roles and may work already.'}
|
|
47
|
+
names = ', '.join(s.get('displayName') or s['subscriptionId'] for s in subs[:5])
|
|
48
|
+
return {'ok': True, 'detail': f'authenticated · {len(subs)} subscription(s): {names}'}
|
|
49
|
+
except Exception as e:
|
|
50
|
+
return {'ok': False, 'error': str(e)[:500]}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def discover(store, cfg: dict, connector_id: int, actor: str = 'owner') -> dict:
|
|
54
|
+
"""What can this app SEE? ARM enumerates the storage accounts (then their containers,
|
|
55
|
+
with the storage token) and Log Analytics workspaces across every visible subscription;
|
|
56
|
+
each is registered as a source with the same mode picker as AWS - report (default,
|
|
57
|
+
nothing polled), feed, tasks, or off. RBAC decides what enumerates; partial failures
|
|
58
|
+
register what did."""
|
|
59
|
+
known = {s['Address'] for s in store.list_sources(active_only=False) if s['Channel'] == 'azure'}
|
|
60
|
+
found, cfgs = [], {}
|
|
61
|
+
tok = token(cfg, f'{ARM}/.default')
|
|
62
|
+
subs = _get(f'{ARM}/subscriptions', tok, params={'api-version': '2022-12-01'}).json().get('value') or []
|
|
63
|
+
stok = None
|
|
64
|
+
for sub in subs[:5]:
|
|
65
|
+
sid = sub['subscriptionId']
|
|
66
|
+
try:
|
|
67
|
+
for sa in _get(f'{ARM}/subscriptions/{sid}/providers/Microsoft.Storage/storageAccounts',
|
|
68
|
+
tok, params={'api-version': '2023-01-01'}).json().get('value') or []:
|
|
69
|
+
acct = sa['name']
|
|
70
|
+
try:
|
|
71
|
+
stok = stok or token(cfg, 'https://storage.azure.com/.default')
|
|
72
|
+
xml = _get(f'https://{acct}.blob.core.windows.net/', stok, params={'comp': 'list'}).text
|
|
73
|
+
for cont in re.findall(r'<Name>(.*?)</Name>', xml)[:20]:
|
|
74
|
+
found.append(f'blob://{acct}/{cont}')
|
|
75
|
+
except Exception as e:
|
|
76
|
+
logger.warning(f'azure discovery: containers of {acct} failed: {e}')
|
|
77
|
+
found.append(f'blob://{acct}') # the account still shows; add /container by hand
|
|
78
|
+
except Exception as e:
|
|
79
|
+
logger.warning(f'azure discovery: storage accounts in {sid} failed: {e}')
|
|
80
|
+
try:
|
|
81
|
+
for ws in _get(f'{ARM}/subscriptions/{sid}/providers/Microsoft.OperationalInsights/workspaces',
|
|
82
|
+
tok, params={'api-version': '2022-10-01'}).json().get('value') or []:
|
|
83
|
+
wid = (ws.get('properties') or {}).get('customerId')
|
|
84
|
+
if not wid: continue
|
|
85
|
+
addr = f"law://{ws['name']}"
|
|
86
|
+
found.append(addr)
|
|
87
|
+
cfgs[addr] = {'workspace_id': wid}
|
|
88
|
+
except Exception as e:
|
|
89
|
+
logger.warning(f'azure discovery: workspaces in {sid} failed: {e}')
|
|
90
|
+
added = 0
|
|
91
|
+
for addr in found:
|
|
92
|
+
if addr in known: continue
|
|
93
|
+
store.save_source({'Channel': 'azure', 'Address': addr, 'ConnectorId': connector_id, 'Active': 1,
|
|
94
|
+
'Owner': 'discovered',
|
|
95
|
+
'ConfigJson': json.dumps({'mode': 'report', **cfgs.get(addr, {})})}, actor)
|
|
96
|
+
added += 1
|
|
97
|
+
out = {'found': len(found), 'added': added}
|
|
98
|
+
if not found: out['hint'] = _why_empty(cfg, tok, subs)
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _why_empty(cfg, tok, subs) -> str:
|
|
103
|
+
"""Nothing found is almost never 'nothing exists' - it is RBAC, and the shape of the
|
|
104
|
+
emptiness says WHICH gap. Azure hands out container reads and resource reads
|
|
105
|
+
separately, so an app can list 86 resource groups and still read nothing inside them."""
|
|
106
|
+
if not subs:
|
|
107
|
+
return ('the app has no role on any subscription - assign it Reader: Azure Portal → '
|
|
108
|
+
'Subscriptions → your subscription → Access control (IAM) → Add role assignment')
|
|
109
|
+
sid = subs[0]['subscriptionId']
|
|
110
|
+
try:
|
|
111
|
+
rgs = len(_get(f'{ARM}/subscriptions/{sid}/resourcegroups', tok,
|
|
112
|
+
params={'api-version': '2021-04-01'}).json().get('value') or [])
|
|
113
|
+
res = len(_get(f'{ARM}/subscriptions/{sid}/resources', tok,
|
|
114
|
+
params={'api-version': '2021-04-01', '$top': 1}).json().get('value') or [])
|
|
115
|
+
except Exception as e:
|
|
116
|
+
return f'could not tell why: {str(e)[:120]}'
|
|
117
|
+
if rgs and not res:
|
|
118
|
+
return (f'the app sees the subscription and its {rgs} resource groups but CANNOT READ THE '
|
|
119
|
+
'RESOURCES inside them - those are separate rights in Azure. Assign it Reader on '
|
|
120
|
+
'the subscription (or on the resource groups holding your storage accounts and '
|
|
121
|
+
'Log Analytics workspaces): Access control (IAM) → Add role assignment → Reader')
|
|
122
|
+
if not rgs:
|
|
123
|
+
return 'the app can see the subscription but none of its resource groups - it needs Reader on the subscription'
|
|
124
|
+
return ('the app can read resources, but this subscription holds no storage accounts and no Log '
|
|
125
|
+
'Analytics workspaces (a VM estate with no diagnostics storage looks exactly like this)')
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def poll_source(store, cfg: dict, src: dict, since, llm=None, file_only=False) -> int:
|
|
129
|
+
"""One discovered object in tasks/feed mode. blob://account/container -> a Timeline
|
|
130
|
+
item per NEW blob; law://workspace -> ONE batched item of new exception rows (the
|
|
131
|
+
source's own 'query' overrides the default KQL)."""
|
|
132
|
+
from .ingest import ingest_message
|
|
133
|
+
scfg = json.loads(src.get('ConfigJson') or '{}')
|
|
134
|
+
addr, floor, n = src['Address'], since.strftime('%Y-%m-%d %H:%M:%S'), 0
|
|
135
|
+
if addr.startswith('blob://') and '/' in addr[7:]:
|
|
136
|
+
acct, cont = addr[7:].split('/', 1)
|
|
137
|
+
tok = token(cfg, 'https://storage.azure.com/.default')
|
|
138
|
+
xml = _get(f'https://{acct}.blob.core.windows.net/{cont}', tok,
|
|
139
|
+
params={'restype': 'container', 'comp': 'list'}).text
|
|
140
|
+
for name, mod in re.findall(r'<Name>(.*?)</Name>.*?<Last-Modified>(.*?)</Last-Modified>', xml, re.S)[:200]:
|
|
141
|
+
try: stamp = datetime.strptime(mod, '%a, %d %b %Y %H:%M:%S GMT').astimezone().strftime('%Y-%m-%d %H:%M:%S')
|
|
142
|
+
except ValueError: continue
|
|
143
|
+
if stamp < floor: continue
|
|
144
|
+
out = ingest_message(store, file_only=file_only, msg={
|
|
145
|
+
'external_id': f'azblob:{acct}/{cont}/{name}:{stamp[:16]}', 'channel': 'azure',
|
|
146
|
+
'subject': f'New in {addr}: {name}',
|
|
147
|
+
'body': f'[blob landed]\nhttps://{acct}.blob.core.windows.net/{cont}/{name}',
|
|
148
|
+
'from_name': addr, 'conversation_id': f'azure:{addr}', 'sent_at': stamp,
|
|
149
|
+
'source_name': addr}, llm=llm)
|
|
150
|
+
n += out['status'] != 'duplicate'
|
|
151
|
+
elif addr.startswith('law://') and scfg.get('workspace_id'):
|
|
152
|
+
hours = max(0.2, (datetime.now() - since.replace(tzinfo=None)).total_seconds() / 3600)
|
|
153
|
+
q = scfg.get('query') or 'AppExceptions | project TimeGenerated, ProblemId, OuterMessage | take 50'
|
|
154
|
+
try:
|
|
155
|
+
head, body = run_azure_logs({**cfg, 'workspace_id': scfg['workspace_id'], 'query': q,
|
|
156
|
+
'hours': hours, 'max_rows': 50})
|
|
157
|
+
except Exception as e:
|
|
158
|
+
raise RuntimeError(f'{addr}: {e}') from e
|
|
159
|
+
if body.strip() and not head.startswith('0 '):
|
|
160
|
+
stamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
161
|
+
out = ingest_message(store, file_only=file_only, msg={
|
|
162
|
+
'external_id': f'azlaw:{addr}:{stamp[:16]}', 'channel': 'azure',
|
|
163
|
+
'subject': f'{head} from {addr}',
|
|
164
|
+
'body': f'[Log Analytics - {q[:100]}]\n{body[:8000]}',
|
|
165
|
+
'from_name': addr, 'conversation_id': f'azure:{addr}', 'sent_at': stamp,
|
|
166
|
+
'source_name': addr}, llm=llm)
|
|
167
|
+
n += out['status'] != 'duplicate'
|
|
168
|
+
return n
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ── Entra ID (the directory, over Graph) ────────────────────────────────────────────────
|
|
172
|
+
# The SAME app registration - one client-credentials token, a different scope. An app that
|
|
173
|
+
# reads mail for the Outlook card can read the directory too once it has the Graph
|
|
174
|
+
# APPLICATION permissions (User.Read.All, Group.Read.All, AuditLog.Read.All,
|
|
175
|
+
# Organization.Read.All); admin consent is what turns each one on.
|
|
176
|
+
GRAPH = 'https://graph.microsoft.com/v1.0'
|
|
177
|
+
GRAPH_PAGES = 12 # $top=999 a page; a 10k-user tenant stops being a report
|
|
178
|
+
|
|
179
|
+
def graph_get(cfg: dict, path: str, **params) -> list:
|
|
180
|
+
"""Every page of a Graph collection, following @odata.nextLink. Advanced queries
|
|
181
|
+
($filter on any(), $count) need the eventual-consistency header, so it always rides."""
|
|
182
|
+
tok = token(cfg, 'https://graph.microsoft.com/.default')
|
|
183
|
+
url, out, pages = f'{GRAPH}{path}', [], 0
|
|
184
|
+
while url and pages < GRAPH_PAGES:
|
|
185
|
+
r = requests.get(url, headers={'Authorization': f'Bearer {tok}', 'ConsistencyLevel': 'eventual'},
|
|
186
|
+
params=params or None, timeout=45)
|
|
187
|
+
if r.status_code >= 400: raise RuntimeError(f'graph {path} said {r.status_code}: {r.text[:300]}')
|
|
188
|
+
j = r.json()
|
|
189
|
+
out += j.get('value') or []
|
|
190
|
+
url, params, pages = j.get('@odata.nextLink'), None, pages + 1
|
|
191
|
+
return out
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def run_entra_users(cfg: dict):
|
|
195
|
+
"""{"filter": "...", "select": "..."} - the directory's people. accountEnabled is NOT in
|
|
196
|
+
Graph's default user payload, so it is selected explicitly: without it every disabled
|
|
197
|
+
account reads as active, which is the one thing an access review must not get wrong."""
|
|
198
|
+
from .reports import row_limit, rows_out
|
|
199
|
+
sel = cfg.get('select') or 'displayName,userPrincipalName,accountEnabled,jobTitle,department,createdDateTime'
|
|
200
|
+
rows = graph_get(cfg, '/users', **{'$select': sel, '$top': 999,
|
|
201
|
+
**({'$filter': cfg['filter']} if cfg.get('filter') else {})})
|
|
202
|
+
lim, mine = row_limit(cfg)
|
|
203
|
+
return rows_out(rows, lim, unit='users', mine=mine)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def run_entra_groups(cfg: dict):
|
|
207
|
+
"""{"group": "<name or id>"} lists that group's TRANSITIVE members (nested groups
|
|
208
|
+
included - what a login actually inherits); blank lists the groups themselves."""
|
|
209
|
+
from .reports import row_limit, rows_out
|
|
210
|
+
lim, mine = row_limit(cfg)
|
|
211
|
+
g = (cfg.get('group') or '').strip()
|
|
212
|
+
if not g:
|
|
213
|
+
rows = graph_get(cfg, '/groups', **{'$select': 'displayName,id,mail,description', '$top': 999})
|
|
214
|
+
return rows_out(rows, lim, unit='groups', mine=mine)
|
|
215
|
+
gid = g
|
|
216
|
+
if not re.fullmatch(r'[0-9a-fA-F-]{36}', g):
|
|
217
|
+
hit = graph_get(cfg, '/groups', **{'$filter': f"displayName eq '{g}'", '$select': 'id,displayName'})
|
|
218
|
+
if not hit: raise RuntimeError(f'no group named {g!r}')
|
|
219
|
+
gid = hit[0]['id']
|
|
220
|
+
rows = graph_get(cfg, f'/groups/{gid}/transitiveMembers',
|
|
221
|
+
**{'$select': 'displayName,userPrincipalName,accountEnabled', '$top': 999})
|
|
222
|
+
# $select drops @odata.type, so a user is 'the thing with a UPN' - nested groups and
|
|
223
|
+
# devices come back through the same collection
|
|
224
|
+
rows = [r for r in rows if r.get('userPrincipalName')]
|
|
225
|
+
return rows_out(rows, lim, unit='members', mine=mine)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def run_entra_signins(cfg: dict):
|
|
229
|
+
"""{"hours": 24, "failed_only": true} - sign-in activity. Needs AuditLog.Read.All AND
|
|
230
|
+
an Entra ID P1/P2 tenant; without the licence Graph answers 403 and says so."""
|
|
231
|
+
from .reports import row_limit, rows_out
|
|
232
|
+
from datetime import timedelta
|
|
233
|
+
since = (datetime.utcnow() - timedelta(hours=float(cfg.get('hours') or 24))).strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
234
|
+
flt = f'createdDateTime ge {since}'
|
|
235
|
+
if cfg.get('failed_only'): flt += ' and status/errorCode ne 0'
|
|
236
|
+
rows = graph_get(cfg, '/auditLogs/signIns', **{'$filter': flt, '$top': 999})
|
|
237
|
+
out = [{'at': r.get('createdDateTime'), 'user': r.get('userPrincipalName'), 'app': r.get('appDisplayName'),
|
|
238
|
+
'ip': r.get('ipAddress'), 'city': ((r.get('location') or {}).get('city')),
|
|
239
|
+
'error': ((r.get('status') or {}).get('errorCode')),
|
|
240
|
+
'reason': ((r.get('status') or {}).get('failureReason') or '')[:120]} for r in rows]
|
|
241
|
+
lim, mine = row_limit(cfg)
|
|
242
|
+
return rows_out(out, lim, unit='sign-ins', mine=mine)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def run_entra_licenses(cfg: dict):
|
|
246
|
+
"""The tenant's licence SKUs and how many seats are actually consumed - the report that
|
|
247
|
+
finds the seats nobody is using. Needs Organization.Read.All."""
|
|
248
|
+
from .reports import row_limit, rows_out
|
|
249
|
+
rows = graph_get(cfg, '/subscribedSkus')
|
|
250
|
+
out = []
|
|
251
|
+
for s in rows:
|
|
252
|
+
p = s.get('prepaidUnits') or {}
|
|
253
|
+
enabled, used = int(p.get('enabled') or 0), int(s.get('consumedUnits') or 0)
|
|
254
|
+
out.append({'sku': s.get('skuPartNumber'), 'enabled': enabled, 'consumed': used,
|
|
255
|
+
'spare': enabled - used, 'warning': int(p.get('warning') or 0),
|
|
256
|
+
'suspended': int(p.get('suspended') or 0)})
|
|
257
|
+
out.sort(key=lambda x: -x['spare'])
|
|
258
|
+
lim, mine = row_limit(cfg)
|
|
259
|
+
return rows_out(out, lim, unit='SKUs', mine=mine)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_entra(cfg: dict) -> dict:
|
|
263
|
+
"""Which directory reads this app actually has - each permission probed separately, so
|
|
264
|
+
the answer names the ones missing instead of failing on the first."""
|
|
265
|
+
out, ok = {}, []
|
|
266
|
+
for label, path, params in (('users', '/users', {'$top': 1, '$select': 'displayName'}),
|
|
267
|
+
('groups', '/groups', {'$top': 1, '$select': 'displayName'}),
|
|
268
|
+
('licences', '/subscribedSkus', {}),
|
|
269
|
+
('sign-in logs', '/auditLogs/signIns', {'$top': 1})):
|
|
270
|
+
try:
|
|
271
|
+
graph_get(cfg, path, **params)
|
|
272
|
+
out[label] = 'ok'; ok.append(label)
|
|
273
|
+
except Exception as e:
|
|
274
|
+
out[label] = 'no' if '403' in str(e) or '401' in str(e) else str(e)[:80]
|
|
275
|
+
if not ok:
|
|
276
|
+
return {'ok': False, 'error': 'the app has no directory permissions - grant Graph APPLICATION '
|
|
277
|
+
'permissions (User.Read.All, Group.Read.All, Organization.Read.All, '
|
|
278
|
+
'AuditLog.Read.All) and click Grant admin consent'}
|
|
279
|
+
missing = [k for k, v in out.items() if v != 'ok']
|
|
280
|
+
return {'ok': True, 'detail': 'Entra reads available: ' + ', '.join(ok)
|
|
281
|
+
+ (f" · not permitted: {', '.join(missing)}" if missing else '')}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def run_azure(cfg: dict):
|
|
285
|
+
"""{"path": "/subscriptions/<id>/..." (or a full https URL), "api_version",
|
|
286
|
+
"path_expr": "a.b"} - GET any ARM object with the app's token. An ARM list (a 'value'
|
|
287
|
+
array) comes back row-capped and honest; a single object comes back as JSON."""
|
|
288
|
+
from .reports import row_limit, rows_out, BODY_CHARS
|
|
289
|
+
from .aws import dot_path
|
|
290
|
+
url = cfg['path'] if str(cfg.get('path') or '').startswith('http') else ARM + cfg['path']
|
|
291
|
+
params = dict(cfg.get('params') or {})
|
|
292
|
+
params.setdefault('api-version', cfg.get('api_version') or '2022-12-01')
|
|
293
|
+
data = _get(url, token(cfg, f'{ARM}/.default'), params=params).json()
|
|
294
|
+
if cfg.get('path_expr'): data = dot_path(data, cfg['path_expr'])
|
|
295
|
+
if isinstance(data, dict) and isinstance(data.get('value'), list): data = data['value']
|
|
296
|
+
if isinstance(data, list):
|
|
297
|
+
lim, mine = row_limit(cfg)
|
|
298
|
+
return rows_out(data, lim, unit='items', mine=mine)
|
|
299
|
+
return 'ok', json.dumps(data, indent=1, default=str)[:BODY_CHARS]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def run_azure_blob(cfg: dict):
|
|
303
|
+
"""{"account", "container", "blob"} reads the blob (text head); {"account", "container",
|
|
304
|
+
"prefix"} lists. Role: 'Storage Blob Data Reader' on the account (or container)."""
|
|
305
|
+
from .reports import row_limit, rows_out, BODY_CHARS
|
|
306
|
+
tok = token(cfg, 'https://storage.azure.com/.default')
|
|
307
|
+
base = f"https://{cfg['account']}.blob.core.windows.net/{cfg['container']}"
|
|
308
|
+
if cfg.get('blob'):
|
|
309
|
+
r = _get(f"{base}/{cfg['blob']}", tok)
|
|
310
|
+
head = (f"{cfg['container']}/{cfg['blob']} · {len(r.content)} bytes"
|
|
311
|
+
+ (' (shown truncated)' if len(r.content) > BODY_CHARS else ''))
|
|
312
|
+
return head, r.content[:BODY_CHARS].decode('utf-8', errors='replace')
|
|
313
|
+
xml = _get(base, tok, params={'restype': 'container', 'comp': 'list', 'prefix': cfg.get('prefix') or ''}).text
|
|
314
|
+
rows = [{'name': n, 'modified': m, 'size': int(s)} for n, m, s in
|
|
315
|
+
re.findall(r'<Name>(.*?)</Name>.*?<Last-Modified>(.*?)</Last-Modified>.*?<Content-Length>(\d+)</Content-Length>',
|
|
316
|
+
xml, re.S)]
|
|
317
|
+
lim, mine = row_limit(cfg)
|
|
318
|
+
return rows_out(rows, lim, unit='blobs', mine=mine)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def run_azure_logs(cfg: dict):
|
|
322
|
+
"""{"workspace_id", "query" (KQL), "hours": 24} - Log Analytics: app exceptions,
|
|
323
|
+
sign-ins, anything the workspace collects. Role: 'Log Analytics Reader'."""
|
|
324
|
+
from .reports import row_limit, rows_out
|
|
325
|
+
tok = token(cfg, 'https://api.loganalytics.io/.default')
|
|
326
|
+
r = requests.post(f"https://api.loganalytics.io/v1/workspaces/{cfg['workspace_id']}/query",
|
|
327
|
+
headers={'Authorization': f'Bearer {tok}'}, timeout=60,
|
|
328
|
+
json={'query': cfg['query'], 'timespan': f"PT{float(cfg.get('hours') or 24):g}H"})
|
|
329
|
+
if r.status_code >= 400: raise RuntimeError(f'{r.status_code}: {r.text[:300]}')
|
|
330
|
+
t = (r.json().get('tables') or [{}])[0]
|
|
331
|
+
cols = [c['name'] for c in t.get('columns') or []]
|
|
332
|
+
rows = [dict(zip(cols, row)) for row in t.get('rows') or []]
|
|
333
|
+
lim, mine = row_limit(cfg)
|
|
334
|
+
return rows_out(rows, lim, mine=mine)
|